This commit is contained in:
k
2026-09-21 18:15:13 +02:00
parent 670d7f1dd1
commit 2a34d16b52
8 changed files with 23 additions and 14 deletions
+4 -3
View File
@@ -19,7 +19,7 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
- `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`, - `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`,
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401 `{ error }` parsing, `ApiError` with status, auto logout+reload on 401
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`. (except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
- Boot shows `LoadingScreen` for at least 2.5 s (`MIN_SPLASH_MS` in `App.tsx`) - Boot shows `LoadingScreen` for at least 1.5 s (`MIN_SPLASH_MS` in `App.tsx`)
so the bloom animation always plays, even when the session restores instantly. so the bloom animation always plays, even when the session restores instantly.
- Sending: ensure a server conversation exists (created lazily on first send), - Sending: ensure a server conversation exists (created lazily on first send),
optimistic user message, stream tokens into `streaming`, then append the full optimistic user message, stream tokens into `streaming`, then append the full
@@ -57,8 +57,9 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
`/api/admin/settings`), SMTP viewer (password blurred by default, eye toggle `/api/admin/settings`), SMTP viewer (password blurred by default, eye toggle
to reveal) + connectivity tester (verify + send test mail). Follows the platform theme. to reveal) + connectivity tester (verify + send test mail). Follows the platform theme.
- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated - `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated
(`{ title, topic, model, authorName, authorAvatarUrl, sharedAt, messages }`); (`{ title, topic, model, authorName, authorAvatarUrl, archived, sharedAt, messages }`);
user messages render the author's avatar via `Avatar` (URL or initials). user messages render the author's avatar via `Avatar` (URL or initials);
an "Archived" pill shows when the source conversation is archived.
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and - `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
a copy button. a copy button.
+1 -1
View File
@@ -36,7 +36,7 @@ function reviewIdFromPath(): string | null {
/** Minimum time the boot splash stays visible, so the bloom animation /** Minimum time the boot splash stays visible, so the bloom animation
* actually plays even when the session restores instantly. */ * actually plays even when the session restores instantly. */
const MIN_SPLASH_MS = 2500; const MIN_SPLASH_MS = 1500;
function applyTheme(theme: string) { function applyTheme(theme: string) {
const root = document.documentElement; const root = document.documentElement;
+9 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Bot, CloudOff, Globe, User as UserIcon } from "lucide-react"; import { Archive, Bot, CloudOff, Globe, User as UserIcon } from "lucide-react";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import type { SharedChat } from "../lib/types.ts"; import type { SharedChat } from "../lib/types.ts";
import { AssistantAvatar, Avatar, FlowerMark } from "./ui.tsx"; import { AssistantAvatar, Avatar, FlowerMark } from "./ui.tsx";
@@ -23,11 +23,16 @@ export function SharePage({ publicId }: { publicId: string }) {
<div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3"> <div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3">
<FlowerMark size={36} dynamic={false} /> <FlowerMark size={36} dynamic={false} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-semibold">{chat?.title ?? t("share.fallback")}</p> <p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
{chat && ( {chat && (
<p className="flex items-center gap-1.5 text-xs opacity-60"> <p className="flex flex-wrap items-center gap-1.5 text-xs opacity-60">
<Globe size={11} /> {t("share.by", { name: chat.authorName })} · {new Date(chat.sharedAt).toLocaleDateString()} <Globe size={11} /> by {chat.authorName} · {new Date(chat.sharedAt).toLocaleDateString()}
{chat.model && <> · {chat.model.split(":").pop()}</>} {chat.model && <> · {chat.model.split(":").pop()}</>}
{chat.archived && (
<span className="inline-flex items-center gap-1 rounded-full bg-stone-200/70 px-2 py-0.5 font-medium opacity-100 dark:bg-zinc-800">
<Archive size={11} /> Archived
</span>
)}
</p> </p>
)} )}
</div> </div>
+2 -2
View File
@@ -274,13 +274,13 @@ export function Sidebar({
)} )}
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900"> <div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
<button onClick={onOpenSettings} className="m-0.5 flex w-[calc(100%-4px)] items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title={t("sidebar.settingsTip")}> <button onClick={onOpenSettings} className="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title={t("sidebar.settingsTip")}>
<Avatar name={user.displayName} url={user.avatarUrl} size={34} /> <Avatar name={user.displayName} url={user.avatarUrl} size={34} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{user.displayName}</span> <span className="block truncate text-sm font-medium">{user.displayName}</span>
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? ` · ${t("sidebar.admin")}` : ""}</span> <span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? ` · ${t("sidebar.admin")}` : ""}</span>
</span> </span>
<Settings size={16} className="opacity-50" /> <Settings size={16} className="mr-2 shrink-0 opacity-50" />
</button> </button>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{user.isAdmin && ( {user.isAdmin && (
+3 -3
View File
@@ -498,6 +498,9 @@ export function SettingsModal({
)} )}
</div> </div>
{search.trim() && visibleCats.length === 0 ? (
<p className="px-2 py-4 text-center text-sm opacity-50">{t("settings.noMatch", { q: search.trim() })}</p>
) : (
<div className="flex flex-col gap-4 sm:flex-row"> <div className="flex flex-col gap-4 sm:flex-row">
<nav aria-label="Settings categories" className="flex shrink-0 gap-1.5 overflow-x-auto sm:w-44 sm:flex-col"> <nav aria-label="Settings categories" className="flex shrink-0 gap-1.5 overflow-x-auto sm:w-44 sm:flex-col">
{visibleCats.map((c) => ( {visibleCats.map((c) => (
@@ -635,9 +638,6 @@ export function SettingsModal({
)} )}
</div> </div>
</div> </div>
{search.trim() && visibleCats.length === 0 && (
<p className="px-2 py-4 text-center text-sm opacity-50">{t("settings.noMatch", { q: search.trim() })}</p>
)} )}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
+1
View File
@@ -52,6 +52,7 @@ export interface SharedChat {
model: string; model: string;
authorName: string; authorName: string;
authorAvatarUrl: string; authorAvatarUrl: string;
archived: boolean;
sharedAt: number; sharedAt: number;
messages: ChatMessage[]; messages: ChatMessage[];
} }
+2 -1
View File
@@ -408,7 +408,7 @@ export function deleteShare(convId: string): void {
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId); getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
} }
export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; sharedAt: number } | null { export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null {
const d = getDb(); const d = getDb();
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null; const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
if (!s) return null; if (!s) return null;
@@ -420,6 +420,7 @@ export function getShareByPublic(publicId: string): { conv: ConversationRow; mes
messages: getMessages(conv.id).filter((m) => m.role !== "system"), messages: getMessages(conv.id).filter((m) => m.role !== "system"),
authorName: author?.display_name || "Someone", authorName: author?.display_name || "Someone",
authorAvatarUrl: author?.avatar_url || "", authorAvatarUrl: author?.avatar_url || "",
archived: conv.archived_at !== 0,
sharedAt: s.created_at, sharedAt: s.created_at,
}; };
} }
+1
View File
@@ -201,6 +201,7 @@ const server = Bun.serve({
model: s.conv.model, model: s.conv.model,
authorName: s.authorName, authorName: s.authorName,
authorAvatarUrl: s.authorAvatarUrl, authorAvatarUrl: s.authorAvatarUrl,
archived: s.archived,
sharedAt: s.sharedAt, sharedAt: s.sharedAt,
messages: s.messages.map((m) => ({ role: m.role, content: m.content })), messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
}); });