KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module

This commit is contained in:
k
2026-09-21 11:00:32 +02:00
commit 739c14324a
47 changed files with 8263 additions and 0 deletions
+201
View File
@@ -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>
);
}
+189
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+193
View File
@@ -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>
);
}
+367
View File
@@ -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>
);
}