KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { KeyRound, Pencil, RefreshCw, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import type { User } from "../lib/types.ts";
|
||||
import { Avatar, Button, ConfirmDialog, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx";
|
||||
|
||||
export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<User | null>(null);
|
||||
|
||||
// create form
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.adminList();
|
||||
setUsers(res.users);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Load failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refresh();
|
||||
setShowCreate(false);
|
||||
setFreshKey(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open ]);
|
||||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined });
|
||||
setFreshKey({ username: res.user.username, key: res.key });
|
||||
setUsername("");
|
||||
setDisplayName("");
|
||||
setShowCreate(false);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Create failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate(u: User) {
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.adminRegenerate(u.id);
|
||||
setFreshKey({ username: u.username, key: res.key });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Regenerate failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u: User) {
|
||||
try {
|
||||
await api.adminDelete(u.id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onClose={onClose} title="Accounts" icon={Users} wide>
|
||||
<p className="mb-4 text-sm opacity-70">
|
||||
No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate.
|
||||
</p>
|
||||
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
|
||||
|
||||
{freshKey && (
|
||||
<div className="mb-4 rounded-2xl border border-emerald-500/40 bg-emerald-50 p-4 dark:bg-emerald-950/30">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> Key for @{freshKey.username} — shown once, copy it now:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey.key}</code>
|
||||
<CopyButton text={freshKey.key} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showCreate ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)} className="mb-3">
|
||||
<UserPlus size={15} /> New account
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
|
||||
<div className="min-w-40 flex-1">
|
||||
<Field label="Username"><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
|
||||
</div>
|
||||
<div className="min-w-40 flex-1">
|
||||
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
|
||||
</div>
|
||||
<Button size="sm" type="submit">Create</Button>
|
||||
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{busy ? (
|
||||
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading…</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{users.map((u) => (
|
||||
<li key={u.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
|
||||
<Avatar name={u.displayName} url={u.avatarUrl} size={36} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p>
|
||||
<p className="text-xs opacity-50">theme: {u.theme} · since {new Date(u.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{!u.isAdmin && (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button title="Edit profile" onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
|
||||
<button title="Regenerate key (revokes old sessions)" onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
||||
<button title="Delete account" onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title={`Delete @${deleteTarget?.username}?`}
|
||||
message="This permanently removes the account, all its conversations and its shares."
|
||||
onConfirm={() => deleteTarget && remove(deleteTarget)}
|
||||
/>
|
||||
|
||||
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState("");
|
||||
const [theme, setTheme] = useState("auto");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setDisplayName(user.displayName);
|
||||
setAvatarUrl(user.avatarUrl);
|
||||
setTheme(user.theme);
|
||||
setError("");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
try {
|
||||
await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), theme });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={user !== null} onClose={onClose} title={`Edit @${user?.username}`} icon={Pencil}>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
|
||||
<Field label="Avatar URL"><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
|
||||
<Field label="Theme">
|
||||
<Picker
|
||||
ariaLabel="Theme"
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
align="left"
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bot, Cpu, PanelLeftOpen, SendHorizontal, Share2, Sparkles, User as UserIcon } from "lucide-react";
|
||||
import type { ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
||||
import { Avatar, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
|
||||
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
|
||||
const isUser = msg.role === "user";
|
||||
return (
|
||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
||||
<Avatar name={authorName} url={isUser ? authorAvatar : undefined} size={32} />
|
||||
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||
{isUser ? "You" : "KisAssistant"}
|
||||
</div>
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-emerald-600 px-4 py-2.5 text-[15px] text-white dark:bg-emerald-500 dark:text-zinc-950">
|
||||
{msg.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<Markdown text={msg.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
user,
|
||||
conv,
|
||||
messages,
|
||||
streaming,
|
||||
sending,
|
||||
models,
|
||||
model,
|
||||
onModelChange,
|
||||
onSend,
|
||||
onShare,
|
||||
sidebarCollapsed,
|
||||
onExpandSidebar,
|
||||
}: {
|
||||
user: User;
|
||||
conv: Conversation | null;
|
||||
messages: ChatMessage[];
|
||||
streaming: string;
|
||||
sending: boolean;
|
||||
models: DriverModel[];
|
||||
model: string;
|
||||
onModelChange: (m: string) => void;
|
||||
onSend: (text: string) => void;
|
||||
onShare: () => void;
|
||||
sidebarCollapsed: boolean;
|
||||
onExpandSidebar: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const modelGroups: PickerGroup[] = (() => {
|
||||
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
||||
for (const m of models) {
|
||||
const list = byDriver.get(m.driver) ?? [];
|
||||
list.push({ value: m.id, label: m.label, hint: m.id });
|
||||
byDriver.set(m.driver, list);
|
||||
}
|
||||
return [...byDriver.entries()].map(([driver, options]) => ({ group: driver, options }));
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const el = boxRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, streaming]);
|
||||
|
||||
function autoGrow() {
|
||||
const ta = taRef.current;
|
||||
if (ta) {
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = Math.min(ta.scrollHeight, 200) + "px";
|
||||
}
|
||||
}
|
||||
|
||||
function submit(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
setDraft("");
|
||||
requestAnimationFrame(autoGrow);
|
||||
onSend(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800">
|
||||
{sidebarCollapsed && (
|
||||
<IconButton title="Expand sidebar" onClick={onExpandSidebar}>
|
||||
<PanelLeftOpen size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
<span className="hidden items-center gap-2 font-semibold sm:flex">
|
||||
<Sparkles size={16} className="text-emerald-600 dark:text-emerald-400" />
|
||||
{conv?.title || "New chat"}
|
||||
</span>
|
||||
{conv?.topic && (
|
||||
<span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Picker
|
||||
ariaLabel="Model"
|
||||
icon={Cpu}
|
||||
value={model}
|
||||
onChange={onModelChange}
|
||||
groups={modelGroups}
|
||||
placeholder={models.length === 0 ? "No models" : "Select model…"}
|
||||
disabled={models.length === 0}
|
||||
/>
|
||||
{conv && (
|
||||
<IconButton title="Share publicly" onClick={onShare}>
|
||||
<Share2 size={17} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref={boxRef} className="flex-1 space-y-5 overflow-y-auto px-4 py-6 sm:px-8">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-5">
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="py-16 text-center">
|
||||
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-3xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
|
||||
<Sparkles size={24} />
|
||||
</span>
|
||||
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2>
|
||||
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
||||
<MessageBubble key={i} msg={m} authorAvatar={user.avatarUrl} authorName={user.displayName} />
|
||||
))}
|
||||
{streaming !== "" && (
|
||||
<div className="flex gap-3">
|
||||
<Avatar name="KisAssistant" size={32} />
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
|
||||
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="typing-cursor"><Markdown text={streaming} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sending && streaming === "" && (
|
||||
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">Thinking…</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2 sm:px-8">
|
||||
<form onSubmit={submit} className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 pl-5 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
rows={1}
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value); autoGrow(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="Message KisAssistant…"
|
||||
className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500"
|
||||
style={{ maxHeight: 200 }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || sending}
|
||||
aria-label="Send"
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-emerald-600 text-white shadow transition hover:bg-emerald-500 active:scale-95 disabled:opacity-40 dark:bg-emerald-500 dark:text-zinc-950 dark:hover:bg-emerald-400"
|
||||
>
|
||||
<SendHorizontal size={17} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="py-2 text-center text-xs opacity-50">No cookies — a Bearer key is stored in this browser only.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { KeyRound, Sparkles, User } from "lucide-react";
|
||||
import { api, setToken } from "../lib/api.ts";
|
||||
import type { User as UserType } from "../lib/types.ts";
|
||||
import { Button, Field, Input, Spinner } from "./ui.tsx";
|
||||
|
||||
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.login(username.trim().toLowerCase(), key);
|
||||
setToken(res.token);
|
||||
onLogin(res.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-emerald-50 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-emerald-950/30">
|
||||
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200/70 bg-white/90 p-8 shadow-2xl backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
|
||||
<Sparkles size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">KisAssistant</h1>
|
||||
<p className="text-sm opacity-60">Private assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Field label="Username">
|
||||
<div className="relative">
|
||||
<User size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
|
||||
<Input className="pl-10" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Access key" hint="Admin key lives in the server .env; user keys are issued by the admin.">
|
||||
<div className="relative">
|
||||
<KeyRound size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
{error && <p className="rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
|
||||
<Button type="submit" size="lg" className="w-full" disabled={busy}>
|
||||
{busy ? <Spinner /> : "Unlock"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { CopyButton } from "./ui.tsx";
|
||||
|
||||
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{lang && (
|
||||
<span className="absolute left-4 top-2.5 text-[11px] font-medium uppercase tracking-wide text-stone-400">{lang}</span>
|
||||
)}
|
||||
<div className="absolute right-2.5 top-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className={lang ? "pt-9" : ""}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="md space-y-3 text-[15px] leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre({ children }) {
|
||||
// react-markdown nests <code> inside <pre>; render our block instead.
|
||||
const child = Array.isArray(children) ? children[0] : children;
|
||||
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
|
||||
const className = props?.className || "";
|
||||
const lang = className.replace("language-", "");
|
||||
const code = String(props?.children ?? "").replace(/\n$/, "");
|
||||
return <CodeBlock code={code} lang={lang} />;
|
||||
},
|
||||
code({ children }) {
|
||||
return <code>{children}</code>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bot, Globe, Sparkles, User as UserIcon } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import type { SharedChat } from "../lib/types.ts";
|
||||
import { Avatar } from "./ui.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
|
||||
export function SharePage({ publicId }: { publicId: string }) {
|
||||
const [chat, setChat] = useState<SharedChat | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.publicShare(publicId)
|
||||
.then(setChat)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "Not found"));
|
||||
}, [publicId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white">
|
||||
<Sparkles size={17} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
|
||||
{chat && (
|
||||
<p className="flex items-center gap-1.5 text-xs opacity-60">
|
||||
<Globe size={11} /> by {chat.authorName} · {new Date(chat.sharedAt).toLocaleDateString()}
|
||||
{chat.model && <> · {chat.model.split(":").pop()}</>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a href="/" className="rounded-full bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 dark:bg-emerald-500 dark:text-zinc-950">
|
||||
Open KisAssistant
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-3xl space-y-5 px-4 py-6">
|
||||
{error && <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
|
||||
{!chat && !error && <p className="py-16 text-center text-sm opacity-60">Loading shared chat…</p>}
|
||||
{chat?.messages.map((m, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Avatar name={m.role === "user" ? chat.authorName : "KisAssistant"} size={32} />
|
||||
<div className="min-w-0 max-w-[90%] flex-1">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||
{m.role === "user" ? chat.authorName : "KisAssistant"}
|
||||
</div>
|
||||
{m.role === "user" ? (
|
||||
<div className="whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900">{m.content}</div>
|
||||
) : (
|
||||
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<Markdown text={m.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
PanelLeftClose,
|
||||
Pencil,
|
||||
Settings,
|
||||
Share2,
|
||||
Tag,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type { Conversation, User } from "../lib/types.ts";
|
||||
import { Avatar, Button, ContextMenu, IconButton } from "./ui.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
conv: Conversation;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
user,
|
||||
convs,
|
||||
activeId,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onNew,
|
||||
onSelect,
|
||||
onRename,
|
||||
onTopic,
|
||||
onShare,
|
||||
onDelete,
|
||||
onOpenSettings,
|
||||
onOpenAdmin,
|
||||
onLogout,
|
||||
}: {
|
||||
user: User;
|
||||
convs: Conversation[];
|
||||
activeId: string | null;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onNew: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
onRename: (conv: Conversation) => void;
|
||||
onTopic: (conv: Conversation) => void;
|
||||
onShare: (conv: Conversation) => void;
|
||||
onDelete: (conv: Conversation) => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenAdmin: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
|
||||
function openMenu(e: React.MouseEvent, conv: Conversation) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenu({ x: e.clientX, y: e.clientY, conv });
|
||||
}
|
||||
|
||||
if (collapsed) return null;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-72 shrink-0 flex-col gap-2 border-r border-stone-200/70 bg-white/70 p-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
|
||||
<MessageSquarePlus size={16} />
|
||||
New chat
|
||||
</Button>
|
||||
<IconButton title="Collapse sidebar" onClick={onToggle}>
|
||||
<PanelLeftClose size={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1 overflow-y-auto py-1">
|
||||
{convs.length === 0 && (
|
||||
<p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p>
|
||||
)}
|
||||
{convs.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(c.id)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSelect(c.id)}
|
||||
onContextMenu={(e) => openMenu(e, c)}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center gap-2 rounded-2xl px-3 py-2.5 text-sm transition",
|
||||
c.id === activeId
|
||||
? "bg-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-100"
|
||||
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{c.title}</span>
|
||||
{c.topic && (
|
||||
<span className="mt-0.5 flex items-center gap-1 text-xs opacity-60">
|
||||
<Tag size={11} />
|
||||
<span className="truncate">{c.topic}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Conversation options"
|
||||
onClick={(e) => openMenu(e, c)}
|
||||
className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700"
|
||||
>
|
||||
<MoreVertical size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{menu && (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
items={[
|
||||
{ icon: Pencil, label: "Rename", onClick: () => onRename(menu.conv) },
|
||||
{ icon: Tag, label: "Set topic", onClick: () => onTopic(menu.conv) },
|
||||
{ icon: Share2, label: "Share publicly", onClick: () => onShare(menu.conv) },
|
||||
{ icon: Trash2, label: "Delete", danger: true, onClick: () => onDelete(menu.conv) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<button onClick={onOpenSettings} className="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800">
|
||||
<Avatar name={user.displayName} url={user.avatarUrl} size={34} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{user.displayName}</span>
|
||||
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? " · admin" : ""}</span>
|
||||
</span>
|
||||
<Settings size={16} className="opacity-50" />
|
||||
</button>
|
||||
<div className="flex gap-1.5">
|
||||
{user.isAdmin && (
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title="Manage accounts">
|
||||
<Users size={15} />
|
||||
Accounts
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
|
||||
Log out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Globe, Link2, Pencil, Settings2, Tag, Unplug } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import type { Conversation, User } from "../lib/types.ts";
|
||||
import { Avatar, Button, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx";
|
||||
|
||||
/* ---------- Rename + topic ---------- */
|
||||
|
||||
export function ConvEditDialog({
|
||||
conv,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
conv: Conversation | null;
|
||||
onClose: () => void;
|
||||
onSaved: (c: Conversation) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (conv) {
|
||||
setTitle(conv.title);
|
||||
setTopic(conv.topic);
|
||||
setError("");
|
||||
}
|
||||
}, [conv]);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!conv || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.patchConv(conv.id, { title: title.trim(), topic: topic.trim() });
|
||||
onSaved(res.conversation);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={conv !== null} onClose={onClose} title="Edit conversation" icon={Pencil}>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<Field label="Title">
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} required />
|
||||
</Field>
|
||||
<Field label="Topic" hint="A short theme tag, e.g. cooking, rust, travel.">
|
||||
<div className="relative">
|
||||
<Tag size={15} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
|
||||
<Input className="pl-10" value={topic} onChange={(e) => setTopic(e.target.value)} maxLength={120} placeholder="No topic" />
|
||||
</div>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Save"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Share ---------- */
|
||||
|
||||
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!conv) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
api.share(conv.id)
|
||||
.then((r) => setUrl(window.location.origin + r.url))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "Share failed"))
|
||||
.finally(() => setBusy(false));
|
||||
}, [conv]);
|
||||
|
||||
async function unshare() {
|
||||
if (!conv) return;
|
||||
await api.unshare(conv.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={conv !== null} onClose={onClose} title="Share publicly" icon={Globe}>
|
||||
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> Creating public link…</p>}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
{url && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm opacity-70">Anyone with this link can read <strong>{conv?.title}</strong>. No login required.</p>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<Link2 size={15} className="shrink-0 opacity-50" />
|
||||
<span className="min-w-0 flex-1 truncate">{url}</span>
|
||||
<CopyButton text={url} />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> Unshare</Button>
|
||||
<Button size="sm" onClick={onClose}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Settings (profile) ---------- */
|
||||
|
||||
export function SettingsModal({
|
||||
user,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
user: User | null;
|
||||
onClose: () => void;
|
||||
onSaved: (u: User) => void;
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState("");
|
||||
const [theme, setTheme] = useState("auto");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setDisplayName(user.displayName);
|
||||
setAvatarUrl(user.avatarUrl);
|
||||
setTheme(user.theme);
|
||||
setError("");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!user || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), theme });
|
||||
onSaved(res.user);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={user !== null} onClose={onClose} title="Profile & appearance" icon={Settings2}>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={displayName || "?"} url={avatarUrl || undefined} size={52} />
|
||||
<p className="text-sm opacity-60">@{user?.username}</p>
|
||||
</div>
|
||||
<Field label="Display name">
|
||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required />
|
||||
</Field>
|
||||
<Field label="Avatar URL" hint="Host an image on catbox.moe (or anywhere) and paste the direct link here. Empty = initials.">
|
||||
<Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" inputMode="url" />
|
||||
</Field>
|
||||
<Field label="Theme">
|
||||
<Picker
|
||||
ariaLabel="Theme"
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
align="left"
|
||||
options={[
|
||||
{ value: "auto", label: "Auto (follows system)" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Save"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
|
||||
/* ---------- Button ---------- */
|
||||
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
size?: "sm" | "md" | "lg" | "icon";
|
||||
};
|
||||
|
||||
export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
size === "sm" && "px-3.5 py-1.5 text-sm",
|
||||
size === "md" && "px-5 py-2.5 text-sm",
|
||||
size === "lg" && "px-6 py-3 text-base",
|
||||
size === "icon" && "h-9 w-9",
|
||||
variant === "primary" && "bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:text-zinc-950",
|
||||
variant === "secondary" && "border border-stone-200 bg-white hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800",
|
||||
variant === "ghost" && "hover:bg-stone-200/60 dark:hover:bg-zinc-800",
|
||||
variant === "danger" && "bg-red-600 text-white hover:bg-red-500",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({ className, title, ...props }: ButtonProps & { title: string }) {
|
||||
return <Button variant="ghost" size="icon" title={title} aria-label={title} className={cn("rounded-full", className)} {...props} />;
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
|
||||
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium opacity-80">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="mt-1 block text-xs opacity-60">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"w-full rounded-2xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none transition",
|
||||
"placeholder:text-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20",
|
||||
"dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Picker (framework-styled dropdown, replaces native <select>) ---------- */
|
||||
|
||||
export interface PickerOption {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface PickerGroup {
|
||||
group: string;
|
||||
options: PickerOption[];
|
||||
}
|
||||
|
||||
export function Picker({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
groups,
|
||||
placeholder = "Select…",
|
||||
ariaLabel,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
className,
|
||||
align = "right",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options?: PickerOption[];
|
||||
groups?: PickerGroup[];
|
||||
placeholder?: string;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
|
||||
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [open ]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={cn(
|
||||
"flex max-w-56 items-center gap-2 rounded-full border border-stone-200 bg-white py-2 pl-3.5 pr-3 text-sm font-medium shadow-sm transition",
|
||||
"hover:border-emerald-500/60 hover:shadow disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"dark:border-zinc-700 dark:bg-zinc-900",
|
||||
open && "border-emerald-500 ring-2 ring-emerald-500/20",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={15} className="shrink-0 text-emerald-600 dark:text-emerald-400" />}
|
||||
<span className="truncate">{selected?.label ?? placeholder}</span>
|
||||
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"absolute top-full z-50 mt-2 max-h-72 min-w-52 max-w-72 overflow-y-auto rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900",
|
||||
align === "right" ? "right-0" : "left-0",
|
||||
)}
|
||||
>
|
||||
{all.map((g) => (
|
||||
<div key={g.group || "default"}>
|
||||
{g.group && (
|
||||
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider opacity-50">{g.group}</p>
|
||||
)}
|
||||
{g.options.map((o) => {
|
||||
const active = o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
onChange(o.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition",
|
||||
active
|
||||
? "bg-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-100"
|
||||
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{o.label}</span>
|
||||
{o.hint && <span className="block truncate text-xs opacity-50">{o.hint}</span>}
|
||||
</span>
|
||||
{active && <Check size={15} className="shrink-0 text-emerald-600 dark:text-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Modal / Dialog ---------- */
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
const Icon = icon;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div
|
||||
className={cn(
|
||||
"relative max-h-[85vh] w-full overflow-y-auto rounded-3xl border border-stone-200 bg-white p-6 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900",
|
||||
wide ? "max-w-2xl" : "max-w-md",
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2.5">
|
||||
{Icon && (
|
||||
<span className="grid h-9 w-9 place-items-center rounded-full bg-emerald-600/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button onClick={onClose} aria-label="Close" className="ml-auto rounded-full p-1.5 hover:bg-stone-200/60 dark:hover:bg-zinc-800">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Delete",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title}>
|
||||
<p className="text-sm opacity-80">{message}</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => { onConfirm(); onClose(); }}>{confirmLabel}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Context menu ---------- */
|
||||
|
||||
export interface MenuItem {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: MenuItem[]; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ x, y });
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
setPos({
|
||||
x: Math.min(x, window.innerWidth - r.width - 8),
|
||||
y: Math.min(y, window.innerHeight - r.height - 8),
|
||||
});
|
||||
}
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [x, y, onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={(e) => { e.preventDefault(); onClose(); }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute w-52 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.label}
|
||||
onClick={() => { it.onClick(); onClose(); }}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-sm transition",
|
||||
it.danger
|
||||
? "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
|
||||
)}
|
||||
>
|
||||
<it.icon size={16} />
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Avatar ---------- */
|
||||
|
||||
const GRADIENTS = [
|
||||
"from-emerald-500 to-teal-600",
|
||||
"from-violet-500 to-purple-600",
|
||||
"from-amber-500 to-orange-600",
|
||||
"from-sky-500 to-cyan-600",
|
||||
"from-rose-500 to-pink-600",
|
||||
];
|
||||
|
||||
export function Avatar({ name, url, size = 36 }: { name: string; url?: string; size?: number }) {
|
||||
if (url) {
|
||||
return <img src={url} alt={name} width={size} height={size} className="rounded-full object-cover" style={{ width: size, height: size }} />;
|
||||
}
|
||||
const initial = (name.trim()[0] || "?").toUpperCase();
|
||||
const g = GRADIENTS[(name.charCodeAt(0) || 0) % GRADIENTS.length];
|
||||
return (
|
||||
<span
|
||||
className={cn("grid shrink-0 place-items-center rounded-full bg-gradient-to-br font-semibold text-white", g)}
|
||||
style={{ width: size, height: size, fontSize: size * 0.42 }}
|
||||
aria-label={name}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Misc ---------- */
|
||||
|
||||
export function Spinner({ size = 18 }: { size?: number }) {
|
||||
return <Loader2 size={size} className="animate-spin" />;
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1500);
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium transition hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{done ? <Check size={13} className="text-emerald-500" /> : <Copy size={13} />}
|
||||
{done ? "Copied" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user