achived chats

This commit is contained in:
k
2026-09-24 12:00:32 +02:00
parent a7f0097a11
commit 67f13dbc93
5 changed files with 50 additions and 7 deletions
+4 -2
View File
@@ -60,11 +60,13 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
share button), message list (`Markdown` for assistant, emerald bubble for share button), message list (`Markdown` for assistant, emerald bubble for
user), rounded composer with paperclip attach menu, attachment chips user), rounded composer with paperclip attach menu, attachment chips
(Enter to send, Shift+Enter for newline). With `readOnly` (archived chat) (Enter to send, Shift+Enter for newline). With `readOnly` (archived chat)
the composer and share button are replaced by an unarchive banner. the composer is replaced by an unarchive banner with a share button
(archived chats stay shareable, but an archived public chat cannot go
private again — `DELETE …/share` answers 403 until unarchived).
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link, - `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
copy, unshare), `SettingsModal` (display name, avatar upload box with copy, unshare), `SettingsModal` (display name, avatar upload box with
drag & drop, avatar URL, theme, plus an "Archived chats" category to drag & drop, avatar URL, theme, plus an "Archived chats" category to
view / unarchive / delete archived conversations). view / unarchive / delete / share archived conversations).
- `AdminPanel.tsx` — No-KYC account management: create (key shown once), - `AdminPanel.tsx` — No-KYC account management: create (key shown once),
regenerate (revokes sessions), edit, delete with confirm, plus server-side regenerate (revokes sessions), edit, delete with confirm, plus server-side
search / sort (newest, oldest, A–Z, Z–A) / email filter / pagination. search / sort (newest, oldest, A–Z, Z–A) / email filter / pagination.
+9
View File
@@ -625,6 +625,15 @@ export function Chat({
<Archive size={17} /> <Archive size={17} />
</span> </span>
<p className="min-w-0 flex-1 text-sm opacity-70">{t("archived.readonly")}</p> <p className="min-w-0 flex-1 text-sm opacity-70">{t("archived.readonly")}</p>
<button
type="button"
onClick={onShare}
aria-label={t("sidebar.share")}
title={t("sidebar.share")}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full transition hover:bg-stone-200/70 active:scale-95 dark:hover:bg-zinc-700"
>
<Share2 size={16} className="opacity-70" />
</button>
<button <button
type="button" type="button"
onClick={onUnarchive} onClick={onUnarchive}
+25 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Play, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react"; import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Play, Plug, ScanText, Search, Settings2, Share2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react";
import { api, type UserProvider, type VoicePreview } from "../lib/api.ts"; import { api, type UserProvider, type VoicePreview } from "../lib/api.ts";
import { pushToast } from "../lib/toasts.ts"; import { pushToast } from "../lib/toasts.ts";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
@@ -229,10 +229,17 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
async function unshare() { async function unshare() {
if (!conv) return; if (!conv) return;
setError("");
try {
await api.unshare(conv.id); await api.unshare(conv.id);
pushToast(t("toast.unshared"), { icon: "unshare" }); pushToast(t("toast.unshared"), { icon: "unshare" });
onClose(); onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t("dlg.reqFailed"));
} }
}
const archived = (conv?.archived_at ?? 0) !== 0;
return ( return (
<Modal open={conv !== null} onClose={onClose} title={t("dlg.shareTitle")} icon={Globe}> <Modal open={conv !== null} onClose={onClose} title={t("dlg.shareTitle")} icon={Globe}>
@@ -246,8 +253,13 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
<span className="min-w-0 flex-1 truncate">{url}</span> <span className="min-w-0 flex-1 truncate">{url}</span>
<CopyButton text={url} /> <CopyButton text={url} />
</div> </div>
{archived && <p className="text-xs opacity-60">{t("dlg.unshareBlocked")}</p>}
<div className="flex justify-between"> <div className="flex justify-between">
{!archived ? (
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button> <Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button>
) : (
<span />
)}
<Button size="sm" onClick={onClose}>{t("common.done")}</Button> <Button size="sm" onClick={onClose}>{t("common.done")}</Button>
</div> </div>
</div> </div>
@@ -651,6 +663,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Conversation | null>(null); const [deleteTarget, setDeleteTarget] = useState<Conversation | null>(null);
const [shareTarget, setShareTarget] = useState<Conversation | null>(null);
async function refresh() { async function refresh() {
try { try {
@@ -738,6 +751,16 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
> >
<ArchiveRestore size={15} /> <ArchiveRestore size={15} />
</button> </button>
<button
type="button"
title={t("sidebar.share")}
aria-label={`${t("sidebar.share")}: ${c.title}`}
disabled={busyId === c.id}
onClick={() => setShareTarget(c)}
className="rounded-full p-2 transition hover:bg-stone-100 disabled:opacity-50 dark:hover:bg-zinc-800"
>
<Share2 size={15} />
</button>
<button <button
type="button" type="button"
title={t("common.delete")} title={t("common.delete")}
@@ -753,6 +776,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
))} ))}
</ul> </ul>
)} )}
<ShareModal conv={shareTarget} onClose={() => setShareTarget(null)} />
<ConfirmDialog <ConfirmDialog
open={deleteTarget !== null} open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)} onClose={() => setDeleteTarget(null)}
+6
View File
@@ -160,6 +160,7 @@ const en = {
"dlg.shareIntro": "dlg.shareIntro":
"Anyone with this link can read {title}. No login required.", "Anyone with this link can read {title}. No login required.",
"dlg.unshare": "Unshare", "dlg.unshare": "Unshare",
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
"dlg.reviewOcr": "Review transcription", "dlg.reviewOcr": "Review transcription",
"dlg.reviewText": "Review text attachment", "dlg.reviewText": "Review text attachment",
"dlg.transcribing": "Transcribing the image on the server (OCR)…", "dlg.transcribing": "Transcribing the image on the server (OCR)…",
@@ -881,6 +882,7 @@ const es: Dict = {
"dlg.shareCreating": "Creando enlace público…", "dlg.shareCreating": "Creando enlace público…",
"dlg.shareIntro": "Cualquiera con este enlace puede leer {title}. Sin login.", "dlg.shareIntro": "Cualquiera con este enlace puede leer {title}. Sin login.",
"dlg.unshare": "Dejar de compartir", "dlg.unshare": "Dejar de compartir",
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
"dlg.reviewOcr": "Revisar transcripción", "dlg.reviewOcr": "Revisar transcripción",
"dlg.reviewText": "Revisar adjunto de texto", "dlg.reviewText": "Revisar adjunto de texto",
"dlg.transcribing": "Transcribiendo la imagen en el servidor (OCR)…", "dlg.transcribing": "Transcribiendo la imagen en el servidor (OCR)…",
@@ -1598,6 +1600,7 @@ const fr: Dict = {
"dlg.shareCreating": "Création du lien public…", "dlg.shareCreating": "Création du lien public…",
"dlg.shareIntro": "Quiconque a ce lien peut lire {title}. Sans connexion.", "dlg.shareIntro": "Quiconque a ce lien peut lire {title}. Sans connexion.",
"dlg.unshare": "Ne plus partager", "dlg.unshare": "Ne plus partager",
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
"dlg.reviewOcr": "Relire la transcription", "dlg.reviewOcr": "Relire la transcription",
"dlg.reviewText": "Relire la pièce jointe", "dlg.reviewText": "Relire la pièce jointe",
"dlg.transcribing": "Transcription de l'image sur le serveur (OCR)…", "dlg.transcribing": "Transcription de l'image sur le serveur (OCR)…",
@@ -2309,6 +2312,7 @@ const ru: Dict = {
"dlg.shareCreating": "Создаём публичную ссылку…", "dlg.shareCreating": "Создаём публичную ссылку…",
"dlg.shareIntro": "Каждый с этой ссылкой прочитает {title}. Вход не нужен.", "dlg.shareIntro": "Каждый с этой ссылкой прочитает {title}. Вход не нужен.",
"dlg.unshare": "Закрыть доступ", "dlg.unshare": "Закрыть доступ",
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
"dlg.reviewOcr": "Проверить распознавание", "dlg.reviewOcr": "Проверить распознавание",
"dlg.reviewText": "Проверить вложение", "dlg.reviewText": "Проверить вложение",
"dlg.transcribing": "Распознаём изображение на сервере (OCR)…", "dlg.transcribing": "Распознаём изображение на сервере (OCR)…",
@@ -3011,6 +3015,7 @@ const it: Dict = {
"dlg.shareIntro": "dlg.shareIntro":
"Chiunque abbia questo link può leggere {title}. Senza login.", "Chiunque abbia questo link può leggere {title}. Senza login.",
"dlg.unshare": "Rimuovi condivisione", "dlg.unshare": "Rimuovi condivisione",
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
"dlg.reviewOcr": "Rivedi trascrizione", "dlg.reviewOcr": "Rivedi trascrizione",
"dlg.reviewText": "Rivedi allegato", "dlg.reviewText": "Rivedi allegato",
"dlg.transcribing": "Trascrizione immagine sul server (OCR)…", "dlg.transcribing": "Trascrizione immagine sul server (OCR)…",
@@ -3717,6 +3722,7 @@ const pt: Dict = {
"dlg.shareCreating": "A criar link público…", "dlg.shareCreating": "A criar link público…",
"dlg.shareIntro": "Quem tiver este link pode ler {title}. Sem login.", "dlg.shareIntro": "Quem tiver este link pode ler {title}. Sem login.",
"dlg.unshare": "Remover partilha", "dlg.unshare": "Remover partilha",
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
"dlg.reviewOcr": "Rever transcrição", "dlg.reviewOcr": "Rever transcrição",
"dlg.reviewText": "Rever anexo", "dlg.reviewText": "Rever anexo",
"dlg.transcribing": "A transcrever imagem no servidor (OCR)…", "dlg.transcribing": "A transcrever imagem no servidor (OCR)…",
+2
View File
@@ -1751,6 +1751,8 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false }); return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false });
} }
if (req.method === "DELETE") { if (req.method === "DELETE") {
// Archived public chats stay public: unarchive first to go private.
if (conv.archived_at) return json({ error: "conversation is archived (read-only) — unarchive it to remove the public link" }, 403);
await deleteShare(conv.id); await deleteShare(conv.id);
return json({ ok: true }); return json({ ok: true });
} }