From 9dd8e772a902d4f8dac365e07219f55fdd92af01 Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 22:47:19 +0200 Subject: [PATCH] mmh --- frontend/public/logo.svg | 38 ++++++--- frontend/src/App.tsx | 17 +++- frontend/src/components/Toasts.tsx | 74 +++++++++++++++++ frontend/src/components/dialogs.tsx | 5 ++ frontend/src/components/ui.tsx | 94 +++++++++++++++++----- frontend/src/index.css | 120 +++++++++++++++++++++++++--- frontend/src/lib/i18n.ts | 36 +++++++++ frontend/src/lib/toasts.ts | 74 +++++++++++++++++ 8 files changed, 420 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/Toasts.tsx create mode 100644 frontend/src/lib/toasts.ts diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg index fcba0ff..4e95290 100644 --- a/frontend/public/logo.svg +++ b/frontend/public/logo.svg @@ -1,11 +1,31 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2bffdb5..12149c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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} /> + ); } diff --git a/frontend/src/components/Toasts.tsx b/frontend/src/components/Toasts.tsx new file mode 100644 index 0000000..04ac26a --- /dev/null +++ b/frontend/src/components/Toasts.tsx @@ -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 = { + 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 ( +
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", + )} + > + + + + {toast.text} +
+ ); +} + +/** + * 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 ( +
+ {toasts.map((t) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index c7cca09..f8974e8 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -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 ( diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 813a12d..72f70da 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -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 ( - {petals.map((r, i) => ( - - - + + + + + + + + + + + + + + + + + {outer.map((p, i) => ( + ))} - + {inner.map((p, i) => ( + + ))} + + + + + ); } @@ -605,9 +659,13 @@ export function LoadingScreen() { return (
- -

KisAssistant

+ + +

KisAssistant

{t("loading.tagline")}

+
); diff --git a/frontend/src/index.css b/frontend/src/index.css index 382092f..f2a87bf 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } } diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 33ca5e3..a02ff50 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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.", diff --git a/frontend/src/lib/toasts.ts b/frontend/src/lib/toasts.ts new file mode 100644 index 0000000..5015f2f --- /dev/null +++ b/frontend/src/lib/toasts.ts @@ -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, + ); +}