From 2705dd47b3ff7a42c8b9c617179380ac7a11595c Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 17:14:32 +0200 Subject: [PATCH] . --- frontend/src/App.tsx | 69 ++++++++++++++- frontend/src/components/Chat.tsx | 17 ++++ frontend/src/components/Login.tsx | 6 +- frontend/src/components/dialogs.tsx | 133 +++++++++++++++++++++++++++- frontend/src/lib/i18n.ts | 30 +++++-- 5 files changed, 244 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8fc0ec2..ba4fa2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,6 +53,8 @@ export function App() { const [model, setModel] = useState(""); const [convs, setConvs] = useState([]); const [activeId, setActiveId] = useState(null); + // Conversation opened from "Archived chats" (read-only, not in the sidebar list). + const [archivedConv, setArchivedConv] = useState(null); const [messages, setMessages] = useState([]); const [streaming, setStreaming] = useState(""); const [sending, setSending] = useState(false); @@ -162,12 +164,14 @@ export function App() { setConvs([]); setMessages([]); setActiveId(null); + setArchivedConv(null); setAttachments([]); setFilePreview(null); } async function newChat() { try { + setArchivedConv(null); const res = await api.createConv({ model }); await refreshConvs(res.conversation.id); } catch { @@ -176,7 +180,7 @@ export function App() { } async function send(text: string) { - if (sending || !model) return; + if (sending || !model || archivedConv) return; let convId = activeId; if (!convId) { try { @@ -271,12 +275,65 @@ export function App() { setActiveId(null); setMessages([]); } + if (archivedConv?.id === c.id) { + setArchivedConv(null); + setMessages([]); + } await refreshConvs(); } catch { // ignore } } + /** Archive from the sidebar menu: the chat leaves the side list. */ + async function archiveConv(c: Conversation) { + try { + await api.archiveConv(c.id); + if (activeId === c.id) { + setActiveId(null); + setMessages([]); + } + await refreshConvs(); + } catch { + // ignore + } + } + + /** Open an archived chat (from settings) read-only in the main view. */ + function openArchived(c: Conversation) { + setSettingsOpen(false); + setMobileNav(false); + setArchivedConv(c); + setActiveId(null); + setMessages([]); + api.getConv(c.id).then((res) => setMessages(res.messages)).catch(() => setMessages([])); + } + + /** Unarchive the currently viewed archived chat and select it. */ + async function unarchiveActive() { + if (!archivedConv) return; + try { + await api.unarchiveConv(archivedConv.id); + const id = archivedConv.id; + setArchivedConv(null); + await refreshConvs(id); + } catch { + // ignore + } + } + + /** + * Settings "Archived chats" changed something: refresh the sidebar, and + * drop the read-only view if its conversation was deleted underneath us. + */ + async function handleArchivedChanged(deletedId?: string) { + if (deletedId && archivedConv?.id === deletedId) { + setArchivedConv(null); + setMessages([]); + } + await refreshConvs(); + } + if (checking) { return ; } @@ -285,7 +342,8 @@ export function App() { return ; } - const active = convs.find((c) => c.id === activeId) ?? null; + const active = convs.find((c) => c.id === activeId) ?? archivedConv; + const readOnly = (active?.archived_at ?? 0) !== 0; return (
@@ -296,10 +354,11 @@ export function App() { collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(true)} onNew={() => { newChat(); setMobileNav(false); }} - onSelect={(id) => { setActiveId(id); setMobileNav(false); }} + onSelect={(id) => { setArchivedConv(null); setActiveId(id); setMobileNav(false); }} onRename={setEditConv} onTopic={setEditConv} onShare={setShareConv} + onArchive={archiveConv} onDelete={setDeleteConv} onOpenSettings={() => { setSettingsOpen(true); setMobileNav(false); }} onOpenAdmin={() => { window.location.href = "/admin"; }} @@ -318,6 +377,8 @@ export function App() { onModelChange={setModel} onSend={send} onShare={() => active && setShareConv(active)} + readOnly={readOnly} + onUnarchive={unarchiveActive} sidebarCollapsed={sidebarCollapsed} onExpandSidebar={() => setSidebarCollapsed(false)} onOpenNav={() => setMobileNav(true)} @@ -358,6 +419,8 @@ export function App() { }} // Key rotated or account deleted: sessions are dead server-side. onKeyRotated={() => { setSettingsOpen(false); logout(); }} + onViewArchived={openArchived} + onArchivedChanged={handleArchivedChanged} />
); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index d86a2e7..9a1ab92 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -243,6 +243,22 @@ export function Chat({ )} + {readOnly ? ( +
+ + + +

{t("archived.readonly")}

+ +
+ ) : (
{features.attachments && (
+ )}

diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 986a830..6160360 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -3,7 +3,7 @@ import { ArrowRight, KeyRound, MessageSquareText, ShieldCheck, User, UserPlus, Z import { api, setToken } from "../lib/api.ts"; import { useT } from "../lib/i18n.ts"; import type { User as UserType } from "../lib/types.ts"; -import { Button, FlowerMark, Input, Spinner } from "./ui.tsx"; +import { Button, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx"; import { RecoverDialog, RegisterDialog } from "./dialogs.tsx"; import { AccessRequestModal } from "./ReviewPage.tsx"; @@ -202,6 +202,10 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) { )} setRecoverOpen(false)} from={recoveryFrom} /> +

+
    {PERKS.map((p) => (
  • diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index a4339cb..de28e5e 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, UserPlus, X } from "lucide-react"; +import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, UserPlus, X } from "lucide-react"; import { api } from "../lib/api.ts"; import { cn } from "../lib/cn.ts"; import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts"; @@ -345,11 +345,15 @@ export function SettingsModal({ onClose, onSaved, onKeyRotated, + onViewArchived, + onArchivedChanged, }: { user: User | null; onClose: () => void; onSaved: (u: User) => void; onKeyRotated?: () => void; + onViewArchived: (c: Conversation) => void; + onArchivedChanged: (deletedId?: string) => void; }) { const { t } = useT(); const [displayName, setDisplayName] = useState(""); @@ -382,6 +386,7 @@ export function SettingsModal({ { id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] }, { id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] }, { id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] }, + { id: "archived", title: t("archived.title"), icon: Archive, keywords: ["archived", "archive", "old", "hidden", "read-only", "readonly", "archiv"] }, ] as const; type CatId = (typeof CATS)[number]["id"]; const [cat, setCat] = useState("profile"); @@ -622,6 +627,12 @@ export function SettingsModal({ )} + + {activeCat === "archived" && ( + + + + )} @@ -651,6 +662,126 @@ function SettingsPane({ children }: { children: React.ReactNode }) { ); } +/* ---------- Archived chats (settings): view, unarchive, delete ---------- */ + +function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => void; onChanged: (deletedId?: string) => void }) { + const { t } = useT(); + const [list, setList] = useState(null); + const [error, setError] = useState(""); + const [busyId, setBusyId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + async function refresh() { + try { + const res = await api.archivedConvs(); + setList(res.conversations); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.loadFailed")); + } + } + + useEffect(() => { + refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function unarchive(c: Conversation) { + setBusyId(c.id); + setError(""); + try { + await api.unarchiveConv(c.id); + await refresh(); + onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.saveFailed")); + } finally { + setBusyId(null); + } + } + + async function remove(c: Conversation) { + setBusyId(c.id); + setError(""); + try { + await api.deleteConv(c.id); + setDeleteTarget(null); + await refresh(); + onChanged(c.id); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.deleteFailed")); + } finally { + setBusyId(null); + } + } + + if (list === null && !error) { + return

    {t("common.loading")}

    ; + } + + return ( +
    +

    {t("archived.readonly")}

    + {error &&

    {error}

    } + {(list ?? []).length === 0 ? ( +

    {t("archived.empty")}

    + ) : ( +
      + {(list ?? []).map((c) => ( +
    • + + + +
      +

      {c.title}

      +

      {new Date(c.updated_at).toLocaleDateString()}

      +
      +
      + + + +
      +
    • + ))} +
    + )} + setDeleteTarget(null)} + title={t("archived.deleteTitle")} + message={t("archived.deleteMsg")} + confirmLabel={t("common.delete")} + onConfirm={() => deleteTarget && remove(deleteTarget)} + /> +
    + ); +} + /* ---------- Accent color (settings) ---------- */ function AccentSection() { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 8e47f85..25952c5 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -83,7 +83,7 @@ const en = { "sidebar.adminCenter": "Admin Center", "sidebar.adminCenterTip": "Open the Admin Center", "sidebar.settingsTip": "Profile & settings", - "sidebar.logout": "Log out", + "sidebar.archive": "Archive", "chat.expand": "Expand sidebar", "chat.openNav": "Open chats", @@ -457,6 +457,9 @@ const en = { "admin.editUserTitle": "Edit @{user}", "admin.avatarUrl": "Avatar URL", + + "archived.readonly": "This chat is archived and read-only.", + "archived.unarchive": "Unarchive", } as const; export type StringKey = keyof typeof en; @@ -527,7 +530,7 @@ const es: Dict = { "sidebar.adminCenter": "Centro de admin", "sidebar.adminCenterTip": "Abrir el Centro de admin", "sidebar.settingsTip": "Perfil y ajustes", - "sidebar.logout": "Cerrar sesión", + "sidebar.archive": "Archivar", "chat.expand": "Mostrar barra lateral", "chat.openNav": "Abrir chats", @@ -896,6 +899,9 @@ const es: Dict = { "admin.editUserTitle": "Editar @{user}", "admin.avatarUrl": "URL del avatar", + + "archived.readonly": "Este chat está archivado y es de solo lectura.", + "archived.unarchive": "Desarchivar", }; const fr: Dict = { @@ -963,7 +969,7 @@ const fr: Dict = { "sidebar.adminCenter": "Centre admin", "sidebar.adminCenterTip": "Ouvrir le Centre admin", "sidebar.settingsTip": "Profil et réglages", - "sidebar.logout": "Déconnexion", + "sidebar.archive": "Archiver", "chat.expand": "Afficher la barre latérale", "chat.openNav": "Ouvrir les chats", @@ -1332,6 +1338,9 @@ const fr: Dict = { "admin.editUserTitle": "Modifier @{user}", "admin.avatarUrl": "URL de l'avatar", + + "archived.readonly": "Ce chat est archivé en lecture seule.", + "archived.unarchive": "Désarchiver", }; const ru: Dict = { @@ -1399,7 +1408,7 @@ const ru: Dict = { "sidebar.adminCenter": "Админ-центр", "sidebar.adminCenterTip": "Открыть Админ-центр", "sidebar.settingsTip": "Профиль и настройки", - "sidebar.logout": "Выйти", + "sidebar.archive": "В архив", "chat.expand": "Показать панель", "chat.openNav": "Открыть чаты", @@ -1755,6 +1764,9 @@ const ru: Dict = { "admin.editUserTitle": "Править @{user}", "admin.avatarUrl": "URL аватара", + + "archived.readonly": "Чат в архиве, только чтение.", + "archived.unarchive": "Вернуть", }; const it: Dict = { @@ -1822,7 +1834,7 @@ const it: Dict = { "sidebar.adminCenter": "Centro admin", "sidebar.adminCenterTip": "Apri il Centro admin", "sidebar.settingsTip": "Profilo e impostazioni", - "sidebar.logout": "Esci", + "sidebar.archive": "Archivia", "chat.expand": "Mostra barra laterale", "chat.openNav": "Apri chat", @@ -2184,6 +2196,9 @@ const it: Dict = { "admin.editUserTitle": "Modifica @{user}", "admin.avatarUrl": "URL avatar", + + "archived.readonly": "Questa chat è archiviata e in sola lettura.", + "archived.unarchive": "Ripristina", }; const pt: Dict = { @@ -2251,7 +2266,7 @@ const pt: Dict = { "sidebar.adminCenter": "Centro admin", "sidebar.adminCenterTip": "Abrir o Centro admin", "sidebar.settingsTip": "Perfil e definições", - "sidebar.logout": "Sair", + "sidebar.archive": "Arquivar", "chat.expand": "Mostrar barra lateral", "chat.openNav": "Abrir chats", @@ -2604,6 +2619,9 @@ const pt: Dict = { "admin.editUserTitle": "Editar @{user}", "admin.avatarUrl": "URL do avatar", + + "archived.readonly": "Este chat está arquivado e é só de leitura.", + "archived.unarchive": "Desarquivar", }; const dicts: Record = { en, es, fr, ru, it, pt };