This commit is contained in:
k
2026-09-21 22:47:19 +02:00
parent ec0191f84a
commit 9dd8e772a9
8 changed files with 420 additions and 38 deletions
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Archive, ArchiveRestore, Check, Cpu, Palette, SunMoon, Trash2, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
import { removeToast, TOAST_DISPLAY_MS, useToasts, type Toast, type ToastIcon } from "../lib/toasts.ts";
const ICONS: Record<ToastIcon, LucideIcon> = {
trash: Trash2,
archive: Archive,
unarchive: ArchiveRestore,
model: Cpu,
theme: SunMoon,
accent: Palette,
check: Check,
};
/** Must match the .toast-exit animation duration in index.css. */
const EXIT_MS = 260;
function ToastPill({ toast }: { toast: Toast }) {
const [leaving, setLeaving] = useState(false);
// Full visibility window, then the exit animation. Restarted whenever a
// same-tag push replaces the text (nonce bump).
useEffect(() => {
setLeaving(false);
const t = window.setTimeout(() => setLeaving(true), TOAST_DISPLAY_MS);
return () => window.clearTimeout(t);
}, [toast.nonce]);
// Unmount once the exit animation has played out.
useEffect(() => {
if (!leaving) return;
const t = window.setTimeout(() => removeToast(toast.id), EXIT_MS);
return () => window.clearTimeout(t);
}, [leaving, toast.id]);
const Icon = ICONS[toast.icon];
return (
<div
role="status"
onClick={() => setLeaving(true)}
className={cn(
"pointer-events-auto flex cursor-pointer items-center gap-2.5 rounded-full border border-stone-200 bg-white/95 py-2 pl-2.5 pr-4 shadow-xl backdrop-blur",
"dark:border-zinc-700 dark:bg-zinc-900/95",
leaving ? "toast-exit" : "toast-enter",
)}
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<Icon size={14} />
</span>
<span className="max-w-72 truncate text-sm font-medium">{toast.text}</span>
</div>
);
}
/**
* Bottom-center pill stack. Fixed above the composer (bottom-24), over the
* modals (z-[70] > modal z-50) so settings actions toast visibly too.
*/
export function Toasts() {
const toasts = useToasts();
if (toasts.length === 0) return null;
return (
<div
aria-live="polite"
className="pointer-events-none fixed inset-x-0 bottom-24 z-[70] flex flex-col items-center gap-2 px-4"
>
{toasts.map((t) => (
<ToastPill key={t.id} toast={t} />
))}
</div>
);
}
+5
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react";
import { api, type UserProvider } from "../lib/api.ts";
import { pushToast } from "../lib/toasts.ts";
import { cn } from "../lib/cn.ts";
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
@@ -616,6 +617,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
await api.unarchiveConv(c.id);
await refresh();
onChanged();
pushToast(t("toast.chatUnarchived", { title: c.title }), { icon: "unarchive" });
} catch (err) {
setError(err instanceof Error ? err.message : t("common.saveFailed"));
} finally {
@@ -631,6 +633,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
setDeleteTarget(null);
await refresh();
onChanged(c.id);
pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
} catch (err) {
setError(err instanceof Error ? err.message : t("common.deleteFailed"));
} finally {
@@ -716,12 +719,14 @@ function AccentSection() {
const next = { ...state, presetId };
setState(next);
applyAccent(next);
pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
}
function pickCustom(hex: string) {
const next = { presetId: "custom", customHex: hex };
setState(next);
previewAccent(hex);
pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
}
return (
+76 -18
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
@@ -483,13 +483,33 @@ export function Avatar({ name, url, size = 36 }: { name: string; url?: string; s
/* ---------- Brand logo (single declaration — import anywhere) ---------- */
/**
* The KisAssistant flower mark. Petals follow the runtime accent color,
* heart stays amber — same artwork as the loading screen and favicon.
* The KisAssistant flower mark: a soft five-petal blossom with a lighter
* inner ring, petals following the runtime accent color (gradients read
* the --ka-accent-* triplets), heart in amber radial with a highlight —
* same artwork as the loading screen and favicon. Petals are deliberately
* slightly uneven (sizes/angles) so it reads organic, not geometric.
* `dynamic` controls the bloom animation: pass false for a static,
* full-bloom mark (e.g. thread bars and headers).
*/
export function FlowerMark({ size, className, dynamic = true }: { size?: number; className?: string; dynamic?: boolean }) {
const petals = [0, 60, 120, 180, 240, 300];
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const petalId = `ka-petal-${uid}`;
const petalInId = `ka-petalin-${uid}`;
const coreId = `ka-core-${uid}`;
const outer = [
{ r: 8, ry: 20 },
{ r: 80, ry: 19 },
{ r: 151, ry: 20.5 },
{ r: 223, ry: 19.5 },
{ r: 294, ry: 20 },
];
const inner = [
{ r: 44, ry: 12 },
{ r: 116, ry: 11.5 },
{ r: 188, ry: 12 },
{ r: 259, ry: 11.5 },
{ r: 331, ry: 12 },
];
return (
<svg
width={size}
@@ -499,19 +519,53 @@ export function FlowerMark({ size, className, dynamic = true }: { size?: number;
role="img"
aria-label="KisAssistant"
>
{petals.map((r, i) => (
<g key={r} transform={`rotate(${r} 50 50)`}>
<ellipse
cx="50"
cy="25"
rx="13"
ry="21"
className={dynamic ? "flower-petal fill-accent-500" : "fill-accent-500"}
style={dynamic ? { animationDelay: `${i * 0.22}s` } : undefined}
/>
</g>
<defs>
<linearGradient id={petalId} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stopColor="rgb(var(--ka-accent-600))" />
<stop offset="0.6" stopColor="rgb(var(--ka-accent-500))" />
<stop offset="1" stopColor="rgb(var(--ka-accent-300))" />
</linearGradient>
<linearGradient id={petalInId} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stopColor="rgb(var(--ka-accent-400))" />
<stop offset="1" stopColor="rgb(var(--ka-accent-200))" />
</linearGradient>
<radialGradient id={coreId}>
<stop offset="0" stopColor="#fef3c7" />
<stop offset="0.5" stopColor="#fbbf24" />
<stop offset="1" stopColor="#d97706" />
</radialGradient>
</defs>
{outer.map((p, i) => (
<ellipse
key={p.r}
cx="50"
cy="27"
rx="13.5"
ry={p.ry}
fill={`url(#${petalId})`}
transform={`rotate(${p.r} 50 50)`}
className={dynamic ? "flower-petal" : undefined}
style={dynamic ? { animationDelay: `${i * 0.2}s` } : undefined}
/>
))}
<circle cx="50" cy="50" r="10" className={dynamic ? "flower-core fill-amber-400" : "fill-amber-400"} />
{inner.map((p, i) => (
<ellipse
key={p.r}
cx="50"
cy="34"
rx="8"
ry={p.ry}
fill={`url(#${petalInId})`}
transform={`rotate(${p.r} 50 50)`}
className={dynamic ? "flower-petal-inner" : undefined}
style={dynamic ? { animationDelay: `${i * 0.2 + 0.6}s` } : undefined}
/>
))}
<g className={dynamic ? "flower-core" : undefined}>
<circle cx="50" cy="50" r="10.5" fill="#f59e0b" opacity="0.22" />
<circle cx="50" cy="50" r="8" fill={`url(#${coreId})`} />
<circle cx="47.5" cy="47" r="2.2" fill="#fffbeb" opacity="0.95" />
</g>
</svg>
);
}
@@ -605,9 +659,13 @@ export function LoadingScreen() {
return (
<div className="loading-enter grid min-h-full place-items-center" role="status" aria-label="Loading KisAssistant">
<div className="flex flex-col items-center gap-4 px-6 text-center">
<FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
<p className="font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
<span className="loader-halo">
<span className="loader-orbit" aria-hidden="true" />
<FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
</span>
<p className="loader-title font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
<p className="loading-dots text-sm opacity-60">{t("loading.tagline")}</p>
<span className="loader-bar" aria-hidden="true"><span /></span>
</div>
</div>
);