From 908c673c583c7fe649507bd131d0e12866be32f1 Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 17:08:07 +0200 Subject: [PATCH] / --- frontend/src/components/AccessRequests.tsx | 29 +-- frontend/src/components/Chat.tsx | 8 +- frontend/src/components/ReviewPage.tsx | 196 ++++++++++++++++----- frontend/src/components/Sidebar.tsx | 4 + frontend/src/lib/api.ts | 7 + frontend/src/lib/types.ts | 1 + 6 files changed, 190 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/AccessRequests.tsx b/frontend/src/components/AccessRequests.tsx index cde44fd..4e3d5ec 100644 --- a/frontend/src/components/AccessRequests.tsx +++ b/frontend/src/components/AccessRequests.tsx @@ -120,6 +120,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const { t } = useT(); const [freshKey, setFreshKey] = useState(null); useEffect(() => { @@ -138,7 +139,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange if (res.key) setFreshKey(res.key); onChanged(); } catch (err) { - setError(err instanceof Error ? err.message : "Update failed"); + setError(err instanceof Error ? err.message : t("access.updateFailed")); } finally { setBusy(false); } @@ -156,7 +157,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange setDraft(""); onChanged(); } catch (err) { - setError(err instanceof Error ? err.message : "Send failed"); + setError(err instanceof Error ? err.message : t("access.sendFailed")); } finally { setBusy(false); } @@ -166,7 +167,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
{freshKey && (
-

Account created — key shown once, copy it now:

+

{t("access.accountCreated")}

{freshKey} @@ -184,7 +185,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange : "rounded-tl-lg bg-white dark:bg-zinc-800", )}>

- {m.author === "admin" ? "You (admin)" : `@${request.username}`} · {new Date(m.created_at).toLocaleString()} + {m.author === "admin" ? t("access.youAdmin") : `@${request.username}`} · {new Date(m.created_at).toLocaleString()}

{m.body}

@@ -197,14 +198,14 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange rows={2} value={draft} onChange={(e) => setDraft(e.target.value)} - placeholder="Reply (emailed to the requester)…" - aria-label="Reply to requester" + placeholder={t("access.replyPh")} + aria-label={t("access.replyAria")} className="max-h-32 flex-1 resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500" /> )}
) : (

- Ticket closed ({STATUS_STYLE[request.status].label.toLowerCase()}). - {request.reason && <> Reason: {request.reason}} + {t("access.closedNote", { status: t(`access.status.${request.status}` as StringKey).toLowerCase() })} + {request.reason && <> {t("access.reasonWord")}: {request.reason}}

)} {error &&

{error}

} diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index cb17be3..cfc35cd 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react"; +import { Archive, ArchiveRestore, BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react"; import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts"; import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx"; import { Markdown } from "./Markdown.tsx"; @@ -45,6 +45,8 @@ export function Chat({ onModelChange, onSend, onShare, + onUnarchive, + readOnly, sidebarCollapsed, onExpandSidebar, onOpenNav, @@ -64,6 +66,9 @@ export function Chat({ onModelChange: (m: string) => void; onSend: (text: string) => void; onShare: () => void; + /** Archived chats render read-only; the banner offers to unarchive. */ + readOnly?: boolean; + onUnarchive?: () => void; sidebarCollapsed: boolean; onExpandSidebar: () => void; onOpenNav: () => void; @@ -107,6 +112,7 @@ export function Chat({ function submit(e?: React.FormEvent) { e?.preventDefault(); + if (readOnly) return; const text = draft.trim(); if (!text || sending) return; setDraft(""); diff --git a/frontend/src/components/ReviewPage.tsx b/frontend/src/components/ReviewPage.tsx index c7baa81..1219bac 100644 --- a/frontend/src/components/ReviewPage.tsx +++ b/frontend/src/components/ReviewPage.tsx @@ -1,22 +1,58 @@ import { useEffect, useState } from "react"; -import { CheckCircle2, Clock, MailWarning, MessageSquareText, Search, SendHorizontal, XCircle } from "lucide-react"; +import { + CheckCircle2, + Clock, + MailWarning, + MessageSquareText, + Search, + SendHorizontal, + XCircle, +} from "lucide-react"; import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts"; -import { Button, CopyButton, Field, FlowerMark, Input, Modal, Spinner } from "./ui.tsx"; +import { + Button, + CopyButton, + Field, + FlowerMark, + Input, + Modal, + Spinner, +} from "./ui.tsx"; import { useT, type StringKey } from "../lib/i18n.ts"; import { cn } from "../lib/cn.ts"; -const STATUS_STYLE: Record = { - pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock }, - reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search }, - accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", icon: CheckCircle2 }, - refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400", icon: XCircle }, +const STATUS_STYLE: Record< + AccessRequest["status"], + { cls: string; icon: typeof Clock } +> = { + pending: { + cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", + icon: Clock, + }, + reviewing: { + cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", + icon: Search, + }, + accepted: { + cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", + icon: CheckCircle2, + }, + refused: { + cls: "bg-red-500/10 text-red-600 dark:text-red-400", + icon: XCircle, + }, }; function StatusBadge({ status }: { status: AccessRequest["status"] }) { const { t } = useT(); const s = STATUS_STYLE[status]; return ( - + {t(`review.status.${status}` as StringKey)} @@ -62,27 +98,39 @@ export function ReviewPage({ ticketId }: { ticketId: string }) { setMessages((prev) => [...prev, res.message]); setDraft(""); } catch (err) { - setError(err instanceof Error ? err.message : t("access.sendFailed"); + setError(err instanceof Error ? err.message : t("access.sendFailed")); } finally { setSending(false); } } - const closed = request?.status === "accepted" || request?.status === "refused"; + const closed = + request?.status === "accepted" || request?.status === "refused"; return (
-

{t("review.title")}

+

+ {t("review.title")} +

- {loading &&

{t("review.loadingTicket")}

} + {loading && ( +

+ {t("review.loadingTicket")} +

+ )} {invalid && (
-

{t("review.notFound")}

+

+ {" "} + {t("review.notFound")} +

{t("review.notFoundSub")}

- +
)} @@ -94,7 +142,8 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
{request.reason && (

- {t("review.adminNote")} {request.reason} + {t("review.adminNote")} + {request.reason}

)} {request.status === "accepted" && ( @@ -105,14 +154,26 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
{messages.map((m) => ( -
-
-

{m.author === "admin" ? t("review.admin") : t("review.you")}

+
+
+

+ {m.author === "admin" + ? t("review.admin") + : t("review.you")} +

{m.body}

@@ -140,10 +201,18 @@ export function ReviewPage({ ticketId }: { ticketId: string }) { aria-label={t("chat.send")} className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400" > - {sending ? : } + {sending ? ( + + ) : ( + + )}
- {error &&

{error}

} + {error && ( +

+ {error} +

+ )}

{t("review.mailHint")}

@@ -157,14 +226,23 @@ export function ReviewPage({ ticketId }: { ticketId: string }) { } /** Wishlist form: reserve a username + email with a motivation message. */ -export function AccessRequestModal({ open, onClose }: { open: boolean; onClose: () => void }) { +export function AccessRequestModal({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [message, setMessage] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const { t } = useT(); - const [done, setDone] = useState<{ reviewUrl: string; username: string } | null>(null); + const [done, setDone] = useState<{ + reviewUrl: string; + username: string; + } | null>(null); useEffect(() => { if (open) { @@ -174,7 +252,7 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose: setError(""); setDone(null); } - }, [open ]); + }, [open]); async function submit(e: React.FormEvent) { e.preventDefault(); @@ -189,14 +267,19 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose: }); setDone({ reviewUrl: res.reviewUrl, username: res.request.username }); } catch (err) { - setError(err instanceof Error ? err.message : t("dlg.reqFailed"); + setError(err instanceof Error ? err.message : t("dlg.reqFailed")); } finally { setBusy(false); } } return ( - + {done ? (

@@ -206,23 +289,45 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose: {t("review.reserved", { user: done.username })}

- {done.reviewUrl} + + {done.reviewUrl} +
-
) : (
-

- {t("review.requestIntro")} -

+

{t("review.requestIntro")}

- setUsername(e.target.value)} placeholder="alice" required minLength={2} maxLength={32} /> + setUsername(e.target.value)} + placeholder="alice" + required + minLength={2} + maxLength={32} + /> - setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" required /> + setEmail(e.target.value)} + placeholder="alice@example.com" + inputMode="email" + required + />