mmh
This commit is contained in:
@@ -1,11 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<g>
|
||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981"/>
|
||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(60 50 50)"/>
|
||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(120 50 50)"/>
|
||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(180 50 50)"/>
|
||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(240 50 50)"/>
|
||||
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(300 50 50)"/>
|
||||
<circle cx="50" cy="50" r="10" fill="#fbbf24"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="ka-petal" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stop-color="#047857"/>
|
||||
<stop offset="0.6" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#6ee7b7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ka-petals" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#a7f3d0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="ka-core">
|
||||
<stop offset="0" stop-color="#fef3c7"/>
|
||||
<stop offset="0.5" stop-color="#fbbf24"/>
|
||||
<stop offset="1" stop-color="#d97706"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse cx="50" cy="27" rx="13.5" ry="20" fill="url(#ka-petal)" transform="rotate(8 50 50)"/>
|
||||
<ellipse cx="50" cy="27" rx="13.5" ry="19" fill="url(#ka-petal)" transform="rotate(80 50 50)"/>
|
||||
<ellipse cx="50" cy="27" rx="13.5" ry="20.5" fill="url(#ka-petal)" transform="rotate(151 50 50)"/>
|
||||
<ellipse cx="50" cy="27" rx="13.5" ry="19.5" fill="url(#ka-petal)" transform="rotate(223 50 50)"/>
|
||||
<ellipse cx="50" cy="27" rx="13.5" ry="20" fill="url(#ka-petal)" transform="rotate(294 50 50)"/>
|
||||
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(44 50 50)"/>
|
||||
<ellipse cx="50" cy="34" rx="8" ry="11.5" fill="url(#ka-petals)" transform="rotate(116 50 50)"/>
|
||||
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(188 50 50)"/>
|
||||
<ellipse cx="50" cy="34" rx="8" ry="11.5" fill="url(#ka-petals)" transform="rotate(259 50 50)"/>
|
||||
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(331 50 50)"/>
|
||||
<circle cx="50" cy="50" r="10.5" fill="#f59e0b" opacity="0.22"/>
|
||||
<circle cx="50" cy="50" r="8" fill="url(#ka-core)"/>
|
||||
<circle cx="47.5" cy="47" r="2.2" fill="#fffbeb" opacity="0.95"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 656 B After Width: | Height: | Size: 1.8 KiB |
+16
-1
@@ -25,6 +25,8 @@ import { SharePage } from "./components/SharePage.tsx";
|
||||
import { ResetPage } from "./components/ResetPage.tsx";
|
||||
import { ReviewPage } from "./components/ReviewPage.tsx";
|
||||
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
|
||||
import { Toasts } from "./components/Toasts.tsx";
|
||||
import { pushToast } from "./lib/toasts.ts";
|
||||
import { useT } from "./lib/i18n.ts";
|
||||
|
||||
function shareIdFromPath(): string | null {
|
||||
@@ -448,6 +450,7 @@ export function App() {
|
||||
setMessages([]);
|
||||
}
|
||||
await refreshConvs();
|
||||
pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -462,6 +465,7 @@ export function App() {
|
||||
setMessages([]);
|
||||
}
|
||||
await refreshConvs();
|
||||
pushToast(t("toast.chatArchived", { title: c.title }), { icon: "archive" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -486,6 +490,7 @@ export function App() {
|
||||
try {
|
||||
await api.unarchiveConv(archivedConv.id);
|
||||
const id = archivedConv.id;
|
||||
pushToast(t("toast.chatUnarchived", { title: archivedConv.title }), { icon: "unarchive" });
|
||||
setArchivedConv(null);
|
||||
await refreshConvs(id);
|
||||
} catch {
|
||||
@@ -589,7 +594,13 @@ export function App() {
|
||||
sending={sending}
|
||||
models={models}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
onModelChange={(id) => {
|
||||
setModel(id);
|
||||
pushToast(t("toast.modelChanged", { label: models.find((m) => m.id === id)?.label ?? id }), {
|
||||
icon: "model",
|
||||
tag: "model",
|
||||
});
|
||||
}}
|
||||
onSend={send}
|
||||
onShare={() => active && setShareConv(active)}
|
||||
readOnly={readOnly}
|
||||
@@ -637,6 +648,9 @@ export function App() {
|
||||
user={settingsOpen ? user : null}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={(u) => {
|
||||
if (u.theme !== user?.theme) {
|
||||
pushToast(t("toast.themeChanged"), { icon: "theme", tag: "theme" });
|
||||
}
|
||||
setUser(u);
|
||||
applyTheme(u.theme);
|
||||
}}
|
||||
@@ -649,6 +663,7 @@ export function App() {
|
||||
onArchivedChanged={handleArchivedChanged}
|
||||
onProvidersChanged={handleProvidersChanged}
|
||||
/>
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)`}>
|
||||
<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="25"
|
||||
rx="13"
|
||||
ry="21"
|
||||
className={dynamic ? "flower-petal fill-accent-500" : "fill-accent-500"}
|
||||
style={dynamic ? { animationDelay: `${i * 0.22}s` } : undefined}
|
||||
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}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
<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">
|
||||
<span className="loader-halo">
|
||||
<span className="loader-orbit" aria-hidden="true" />
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
||||
+110
-10
@@ -90,24 +90,32 @@ body {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Loading flower: petals bloom one after another, core breathes. */
|
||||
.flower-petal {
|
||||
/* Loading flower: outer petals bloom in turn, inner petals follow softer,
|
||||
core breathes with an amber glow. */
|
||||
.flower-petal, .flower-petal-inner, .flower-core {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: bloom 1.8s ease-in-out infinite;
|
||||
}
|
||||
.flower-petal {
|
||||
animation: bloom 2.4s ease-in-out infinite;
|
||||
}
|
||||
.flower-petal-inner {
|
||||
animation: bloom-inner 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bloom {
|
||||
0%, 100% { transform: scale(0.55); opacity: 0.3; }
|
||||
0%, 100% { transform: scale(0.62); opacity: 0.35; }
|
||||
50% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes bloom-inner {
|
||||
0%, 100% { transform: scale(0.55); opacity: 0.2; }
|
||||
50% { transform: scale(1); opacity: 0.95; }
|
||||
}
|
||||
.flower-core {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: core-breathe 1.8s ease-in-out infinite;
|
||||
animation: core-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes core-breathe {
|
||||
0%, 100% { transform: scale(0.85); }
|
||||
50% { transform: scale(1.15); }
|
||||
0%, 100% { transform: scale(0.9); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
/* Smooth entrance for full-screen loaders. */
|
||||
@keyframes loading-in {
|
||||
@@ -118,6 +126,81 @@ body {
|
||||
animation: loading-in 0.45s ease-out both;
|
||||
}
|
||||
|
||||
/* Loader halo: expanding accent glow + revolving dashed orbit with a dot. */
|
||||
.loader-halo {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.loader-halo::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: radial-gradient(circle, rgb(var(--ka-accent-500) / 0.28) 0%, transparent 65%);
|
||||
animation: halo-ping 2.6s ease-out infinite;
|
||||
}
|
||||
@keyframes halo-ping {
|
||||
0% { transform: scale(0.72); opacity: 0.6; }
|
||||
70% { transform: scale(1.14); opacity: 0; }
|
||||
100% { transform: scale(1.14); opacity: 0; }
|
||||
}
|
||||
.loader-orbit {
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
border-radius: 9999px;
|
||||
border: 1.5px dashed rgb(var(--ka-accent-500) / 0.45);
|
||||
animation: orbit-spin 5s linear infinite;
|
||||
}
|
||||
.loader-orbit::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3.5px;
|
||||
left: 50%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: -3.5px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--ka-accent-500));
|
||||
box-shadow: 0 0 8px rgb(var(--ka-accent-500));
|
||||
}
|
||||
@keyframes orbit-spin {
|
||||
to { transform: rotate(1turn); }
|
||||
}
|
||||
/* Loader title: soft accent glow pulse. */
|
||||
.loader-title {
|
||||
animation: title-glow 2.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes title-glow {
|
||||
0%, 100% { opacity: 0.92; }
|
||||
50% { opacity: 1; text-shadow: 0 0 18px rgb(var(--ka-accent-500) / 0.35); }
|
||||
}
|
||||
/* Indeterminate shimmer bar. */
|
||||
.loader-bar {
|
||||
display: block;
|
||||
width: 11rem;
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(0 0 0 / 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dark .loader-bar {
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
.loader-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 35%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgb(var(--ka-accent-400)), rgb(var(--ka-accent-600)));
|
||||
animation: bar-slide 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bar-slide {
|
||||
0% { transform: translateX(-110%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
|
||||
.loading-dots::after {
|
||||
content: "";
|
||||
animation: dots 1.4s steps(4) infinite;
|
||||
@@ -129,8 +212,25 @@ body {
|
||||
75% { content: "..."; }
|
||||
}
|
||||
|
||||
/* Bottom pills: springy rise on appear, soft sink on dismiss. */
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(18px) scale(0.94); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.toast-enter {
|
||||
animation: toast-in 0.38s cubic-bezier(0.21, 1.02, 0.73, 1) both;
|
||||
}
|
||||
@keyframes toast-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||
}
|
||||
.toast-exit {
|
||||
animation: toast-out 0.26s ease-in both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after {
|
||||
.flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after,
|
||||
.toast-enter, .toast-exit {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ const en = {
|
||||
"chat.noModels": "No models",
|
||||
"chat.selectModel": "Select model…",
|
||||
"chat.personalTag": "personal key",
|
||||
"toast.chatDeleted": "Chat “{title}” deleted",
|
||||
"toast.chatArchived": "Chat “{title}” archived",
|
||||
"toast.chatUnarchived": "Chat “{title}” unarchived",
|
||||
"toast.modelChanged": "Model: {label}",
|
||||
"toast.themeChanged": "Theme changed",
|
||||
"toast.accentChanged": "Accent color changed",
|
||||
"chat.you": "You",
|
||||
"chat.greet": "How can I help, {name}?",
|
||||
"chat.greetSub": "Pick a model, then type below. Markdown is supported.",
|
||||
@@ -635,6 +641,12 @@ const es: Dict = {
|
||||
"chat.noModels": "Sin modelos",
|
||||
"chat.selectModel": "Elegir modelo…",
|
||||
"chat.personalTag": "clave personal",
|
||||
"toast.chatDeleted": "Chat “{title}” eliminado",
|
||||
"toast.chatArchived": "Chat “{title}” archivado",
|
||||
"toast.chatUnarchived": "Chat “{title}” desarchivado",
|
||||
"toast.modelChanged": "Modelo: {label}",
|
||||
"toast.themeChanged": "Tema cambiado",
|
||||
"toast.accentChanged": "Color de acento cambiado",
|
||||
"chat.you": "Tú",
|
||||
"chat.greet": "¿En qué te ayudo, {name}?",
|
||||
"chat.greetSub": "Elige un modelo y escribe abajo. Markdown soportado.",
|
||||
@@ -1166,6 +1178,12 @@ const fr: Dict = {
|
||||
"chat.noModels": "Aucun modèle",
|
||||
"chat.selectModel": "Choisir un modèle…",
|
||||
"chat.personalTag": "clé personnelle",
|
||||
"toast.chatDeleted": "Chat “{title}” supprimé",
|
||||
"toast.chatArchived": "Chat “{title}” archivé",
|
||||
"toast.chatUnarchived": "Chat “{title}” désarchivé",
|
||||
"toast.modelChanged": "Modèle : {label}",
|
||||
"toast.themeChanged": "Thème modifié",
|
||||
"toast.accentChanged": "Couleur d’accent modifiée",
|
||||
"chat.you": "Vous",
|
||||
"chat.greet": "Comment puis-je aider, {name} ?",
|
||||
"chat.greetSub":
|
||||
@@ -1697,6 +1715,12 @@ const ru: Dict = {
|
||||
"chat.noModels": "Нет моделей",
|
||||
"chat.selectModel": "Выбрать модель…",
|
||||
"chat.personalTag": "свой ключ",
|
||||
"toast.chatDeleted": "Чат “{title}” удалён",
|
||||
"toast.chatArchived": "Чат “{title}” архивирован",
|
||||
"toast.chatUnarchived": "Чат “{title}” разархивирован",
|
||||
"toast.modelChanged": "Модель: {label}",
|
||||
"toast.themeChanged": "Тема изменена",
|
||||
"toast.accentChanged": "Акцентный цвет изменён",
|
||||
"chat.you": "Вы",
|
||||
"chat.greet": "Чем помочь, {name}?",
|
||||
"chat.greetSub": "Выберите модель и пишите ниже. Поддерживается Markdown.",
|
||||
@@ -2215,6 +2239,12 @@ const it: Dict = {
|
||||
"chat.noModels": "Nessun modello",
|
||||
"chat.selectModel": "Scegli modello…",
|
||||
"chat.personalTag": "chiave personale",
|
||||
"toast.chatDeleted": "Chat “{title}” eliminata",
|
||||
"toast.chatArchived": "Chat “{title}” archiviata",
|
||||
"toast.chatUnarchived": "Chat “{title}” disarchiviata",
|
||||
"toast.modelChanged": "Modello: {label}",
|
||||
"toast.themeChanged": "Tema cambiato",
|
||||
"toast.accentChanged": "Colore accento cambiato",
|
||||
"chat.you": "Tu",
|
||||
"chat.greet": "Come posso aiutare, {name}?",
|
||||
"chat.greetSub": "Scegli un modello e scrivi qui sotto. Markdown supportato.",
|
||||
@@ -2739,6 +2769,12 @@ const pt: Dict = {
|
||||
"chat.noModels": "Sem modelos",
|
||||
"chat.selectModel": "Escolher modelo…",
|
||||
"chat.personalTag": "chave pessoal",
|
||||
"toast.chatDeleted": "Chat “{title}” eliminado",
|
||||
"toast.chatArchived": "Chat “{title}” arquivado",
|
||||
"toast.chatUnarchived": "Chat “{title}” desarquivado",
|
||||
"toast.modelChanged": "Modelo: {label}",
|
||||
"toast.themeChanged": "Tema alterado",
|
||||
"toast.accentChanged": "Cor de destaque alterada",
|
||||
"chat.you": "Tu",
|
||||
"chat.greet": "Como posso ajudar, {name}?",
|
||||
"chat.greetSub": "Escolhe um modelo e escreve abaixo. Markdown suportado.",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Bottom-pill notifications (toasts): one-line confirmations when the user
|
||||
* takes an action (chat deleted/archived, model/theme/accent changed…).
|
||||
* Same subscribe pattern as features.ts / i18n.ts — callable from anywhere
|
||||
* (App, settings sections) without prop drilling.
|
||||
*/
|
||||
|
||||
export type ToastIcon =
|
||||
| "trash"
|
||||
| "archive"
|
||||
| "unarchive"
|
||||
| "model"
|
||||
| "theme"
|
||||
| "accent"
|
||||
| "check";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
icon: ToastIcon;
|
||||
/** Same-tag pushes replace the pill instead of stacking (accent, model). */
|
||||
tag: string | null;
|
||||
/** Bumped on replace so the pill restarts its enter/dismiss timers. */
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
/** How long a pill stays fully visible before its exit animation. */
|
||||
export const TOAST_DISPLAY_MS = 3000;
|
||||
|
||||
let nextId = 1;
|
||||
let toasts: Toast[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function pushToast(text: string, opts: { icon?: ToastIcon; tag?: string } = {}): void {
|
||||
const icon = opts.icon ?? "check";
|
||||
const tag = opts.tag ?? null;
|
||||
if (tag) {
|
||||
const existing = toasts.find((t) => t.tag === tag);
|
||||
if (existing) {
|
||||
toasts = toasts.map((t) =>
|
||||
t.id === existing.id ? { ...t, text, icon, nonce: t.nonce + 1 } : t,
|
||||
);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Cap the stack so rapid actions never flood the bottom of the screen.
|
||||
toasts = [...toasts.slice(-2), { id: nextId++, text, icon, tag, nonce: 0 }];
|
||||
emit();
|
||||
}
|
||||
|
||||
export function removeToast(id: number): void {
|
||||
if (!toasts.some((t) => t.id === id)) return;
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
emit();
|
||||
}
|
||||
|
||||
export function useToasts(): Toast[] {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
},
|
||||
() => toasts,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user