mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat(login): implement customizable hotkey management in the login page with enhanced UI components
This commit is contained in:
parent
46056ee514
commit
daac6b5269
1 changed files with 180 additions and 61 deletions
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { BrainCog, Eye, EyeOff, Rocket, Zap } from "lucide-react";
|
||||
import { ArrowBigUp, BrainCog, Command, Eye, EyeOff, Option, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -20,6 +20,157 @@ import { setBearerToken } from "@/lib/auth-utils";
|
|||
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
||||
type ShortcutKey = "generalAssist" | "quickAsk" | "autocomplete";
|
||||
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
|
||||
|
||||
type ShortcutToken =
|
||||
| { kind: "text"; value: string }
|
||||
| { kind: "icon"; value: "command" | "option" | "shift" };
|
||||
|
||||
const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; description: string; icon: React.ElementType }> = [
|
||||
{
|
||||
key: "generalAssist",
|
||||
label: "General Assist",
|
||||
description: "Launch SurfSense instantly from any application",
|
||||
icon: Rocket,
|
||||
},
|
||||
{
|
||||
key: "quickAsk",
|
||||
label: "Quick Assist",
|
||||
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
key: "autocomplete",
|
||||
label: "Extreme Assist",
|
||||
description: "AI drafts text using your screen context and knowledge base",
|
||||
icon: BrainCog,
|
||||
},
|
||||
];
|
||||
|
||||
function acceleratorToTokens(accel: string, isMac: boolean): ShortcutToken[] {
|
||||
if (!accel) return [];
|
||||
return accel.split("+").map((part) => {
|
||||
if (part === "CommandOrControl") {
|
||||
return isMac ? { kind: "icon", value: "command" as const } : { kind: "text", value: "Ctrl" };
|
||||
}
|
||||
if (part === "Alt") {
|
||||
return isMac ? { kind: "icon", value: "option" as const } : { kind: "text", value: "Alt" };
|
||||
}
|
||||
if (part === "Shift") {
|
||||
return isMac ? { kind: "icon", value: "shift" as const } : { kind: "text", value: "Shift" };
|
||||
}
|
||||
if (part === "Space") return { kind: "text", value: "Space" };
|
||||
return { kind: "text", value: part.length === 1 ? part.toUpperCase() : part };
|
||||
});
|
||||
}
|
||||
|
||||
function HotkeyRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
defaultValue,
|
||||
icon: Icon,
|
||||
isMac,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
icon: React.ElementType;
|
||||
isMac: boolean;
|
||||
onChange: (accelerator: string) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const inputRef = useRef<HTMLButtonElement>(null);
|
||||
const isDefault = value === defaultValue;
|
||||
const displayTokens = useMemo(() => acceleratorToTokens(value, isMac), [value, isMac]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!recording) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const accel = keyEventToAccelerator(e);
|
||||
if (accel) {
|
||||
onChange(accel);
|
||||
setRecording(false);
|
||||
}
|
||||
},
|
||||
[onChange, recording]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2.5 border-border/60 border-b py-3 last:border-b-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{label}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={onReset}
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
ref={inputRef}
|
||||
type="button"
|
||||
onClick={() => setRecording(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setRecording(false)}
|
||||
className={
|
||||
recording
|
||||
? "flex h-7 items-center rounded-md border border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "flex h-7 items-center rounded-md border border-input bg-muted/50 hover:bg-muted"
|
||||
}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
|
||||
) : (
|
||||
<>
|
||||
{displayTokens.map((token, idx) => (
|
||||
<span
|
||||
key={`${token.kind}-${idx}`}
|
||||
className="inline-flex h-full min-w-7 items-center justify-center border-border/60 border-r px-1.5 text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
{token.kind === "text" ? (
|
||||
token.value
|
||||
) : token.value === "command" ? (
|
||||
<Command className="size-3" />
|
||||
) : token.value === "option" ? (
|
||||
<Option className="size-3" />
|
||||
) : (
|
||||
<ArrowBigUp className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopLoginPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -33,6 +184,7 @@ export default function DesktopLoginPage() {
|
|||
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
const isMac = api?.versions?.platform === "darwin";
|
||||
|
||||
useEffect(() => {
|
||||
if (!api?.getShortcuts) {
|
||||
|
|
@ -41,7 +193,7 @@ export default function DesktopLoginPage() {
|
|||
}
|
||||
api
|
||||
.getShortcuts()
|
||||
.then((config) => {
|
||||
.then((config: ShortcutMap | null) => {
|
||||
if (config) setShortcuts(config);
|
||||
setShortcutsLoaded(true);
|
||||
})
|
||||
|
|
@ -117,18 +269,8 @@ export default function DesktopLoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh items-center justify-center bg-background p-4 sm:p-6">
|
||||
{/* Subtle radial glow */}
|
||||
<div className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-1/2 left-1/2 size-[800px] -translate-x-1/2 rounded-full opacity-[0.03]"
|
||||
style={{
|
||||
background: "radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full max-w-md flex-col overflow-hidden rounded-xl border bg-card shadow-lg">
|
||||
<div className="relative flex min-h-svh items-center justify-center bg-background p-4 sm:p-6 select-none">
|
||||
<div className="relative flex w-full max-w-md flex-col overflow-hidden bg-card shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
|
||||
<Image
|
||||
|
|
@ -141,7 +283,7 @@ export default function DesktopLoginPage() {
|
|||
/>
|
||||
<h1 className="text-lg font-semibold tracking-tight">Welcome to SurfSense Desktop</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Configure shortcuts, then sign in to get started.
|
||||
Configure shortcuts, then sign in to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -151,41 +293,24 @@ export default function DesktopLoginPage() {
|
|||
{/* ---- Shortcuts ---- */}
|
||||
{shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{/* <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Hotkeys
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.generalAssist}
|
||||
onChange={(accel) => updateShortcut("generalAssist", accel)}
|
||||
onReset={() => resetShortcut("generalAssist")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
|
||||
label="General Assist"
|
||||
description="Launch SurfSense instantly from any application"
|
||||
icon={Rocket}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.quickAsk}
|
||||
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||
onReset={() => resetShortcut("quickAsk")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||
label="Quick Assist"
|
||||
description="Select text anywhere, then ask AI to explain, rewrite, or act on it"
|
||||
icon={Zap}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.autocomplete}
|
||||
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||
onReset={() => resetShortcut("autocomplete")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||
label="Extreme Assist"
|
||||
description="AI drafts text using your screen context and knowledge base"
|
||||
icon={BrainCog}
|
||||
/>
|
||||
</p> */}
|
||||
<div>
|
||||
{HOTKEY_ROWS.map((row) => (
|
||||
<HotkeyRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
description={row.description}
|
||||
value={shortcuts[row.key]}
|
||||
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
||||
icon={row.icon}
|
||||
isMac={isMac}
|
||||
onChange={(accel) => updateShortcut(row.key, accel)}
|
||||
onReset={() => resetShortcut(row.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground text-center mt-1">
|
||||
Click a shortcut and press a new key combination to change it.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-6">
|
||||
|
|
@ -197,9 +322,9 @@ export default function DesktopLoginPage() {
|
|||
|
||||
{/* ---- Auth ---- */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{/* <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Sign In
|
||||
</p>
|
||||
</p> */}
|
||||
|
||||
{isGoogleAuth ? (
|
||||
<Button variant="outline" className="w-full gap-2 h-10" onClick={handleGoogleLogin}>
|
||||
|
|
@ -261,15 +386,9 @@ export default function DesktopLoginPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoggingIn} className="h-9 mt-1">
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="text-primary-foreground" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
<Button type="submit" disabled={isLoggingIn} className="relative h-9 mt-1">
|
||||
<span className={isLoggingIn ? "opacity-0" : ""}>Sign in</span>
|
||||
{isLoggingIn && <Spinner size="sm" className="absolute text-primary-foreground" />}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue