Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
mode and time limit are settings in the database (jobs.settings). Existing
files are adopted on start-up (frontmatter into the database and removed from
the file); plain Markdown files registered later get default settings and stay
switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
a template) create .codecraft/jobs/<name>.md and open the editor: settings with
a graphical cron builder (next runs in server time via /jobs/schedule), the
instructions in the Markdown editor and an assistant chat
(POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.
Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
drop, or the group's menu), stored per user via /account/sidebar. A new
message moves the chat to the top of its group, no longer the project itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
497 lines
21 KiB
TypeScript
497 lines
21 KiB
TypeScript
import { Icon } from "./Icon";
|
||
import { StatusLeds } from "./StatusLeds";
|
||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||
import { get, patch, post, put } from "../api";
|
||
import { promptDialog, toast } from "../dialogs";
|
||
import { fmtRelative, useT, type Key } from "../i18n";
|
||
import { navigate, openBeside } from "../router";
|
||
import { useStore } from "../store";
|
||
import type { Session } from "../types";
|
||
|
||
function basename(p: string) {
|
||
return p.split("/").filter(Boolean).pop() ?? p;
|
||
}
|
||
|
||
function groupKey(ms: number): Key {
|
||
const day = 86400000;
|
||
const startOfToday = new Date().setHours(0, 0, 0, 0);
|
||
if (ms >= startOfToday) return "sidebar.today";
|
||
if (ms >= startOfToday - day) return "sidebar.yesterday";
|
||
if (ms >= startOfToday - 7 * day) return "sidebar.week";
|
||
return "sidebar.older";
|
||
}
|
||
|
||
const GROUP_BY = "codecraft.sidebarGroupBy";
|
||
const COLLAPSED = "codecraft.sidebarCollapsed";
|
||
|
||
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
|
||
|
||
/** One session in the list, with a menu to pin, file or archive it. */
|
||
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
|
||
const t = useT();
|
||
const [menu, setMenu] = useState(false);
|
||
useEffect(() => {
|
||
if (!menu) return;
|
||
const close = () => setMenu(false);
|
||
window.addEventListener("click", close);
|
||
window.addEventListener("keydown", close);
|
||
return () => {
|
||
window.removeEventListener("click", close);
|
||
window.removeEventListener("keydown", close);
|
||
};
|
||
}, [menu]);
|
||
const moveTo = async () => {
|
||
const name = await promptDialog({
|
||
title: t("sidebar.moveTitle"),
|
||
message: folders.length ? t("sidebar.moveHintExisting", { folders: folders.join(", ") }) : t("sidebar.moveHint"),
|
||
defaultValue: s.folder ?? "",
|
||
placeholder: t("sidebar.folderName"),
|
||
confirmLabel: t("sidebar.move"),
|
||
});
|
||
if (name !== null) void organise(s.id, { folder: name });
|
||
};
|
||
return (
|
||
<div className={`session-row ${menu ? "menu-open" : ""}`}>
|
||
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}>
|
||
<div className="title">
|
||
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
|
||
<span>{s.title || t("session.untitled")}</span>
|
||
</div>
|
||
<div className="sub">
|
||
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
|
||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||
</div>
|
||
</button>
|
||
<button
|
||
className="session-more ghost"
|
||
aria-label={t("sidebar.organise")}
|
||
aria-haspopup="menu"
|
||
aria-expanded={menu}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setMenu(!menu);
|
||
}}
|
||
>
|
||
<Icon name="more" />
|
||
</button>
|
||
{menu && (
|
||
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||
{!s.archived && (
|
||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { pinned: !s.pinned }))}>
|
||
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
|
||
</button>
|
||
)}
|
||
<button role="menuitem" className="hide-mobile" onClick={() => (setMenu(false), openBeside(s.id))}>
|
||
<Icon name="external" /> {t("sidebar.openBeside")}
|
||
</button>
|
||
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
|
||
<Icon name="folder" /> {t("sidebar.moveTo")}
|
||
</button>
|
||
{s.folder && (
|
||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { folder: "" }))}>
|
||
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
|
||
</button>
|
||
)}
|
||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
||
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Order and pinning of folders and projects in the list (per user, all devices). */
|
||
interface Layout {
|
||
order: string[];
|
||
pinned: string[];
|
||
}
|
||
|
||
/** Folders and projects can be moved and pinned; time groups and the archive cannot. */
|
||
const movable = (id: string) => id.startsWith("folder:") || id.startsWith("project:");
|
||
|
||
const DRAG_TYPE = "application/x-codecraft-group";
|
||
|
||
/** Header of a group: collapses it; folders and projects also have a menu and can be dragged. */
|
||
function GroupHeader({
|
||
g,
|
||
collapsed,
|
||
pinned,
|
||
onToggle,
|
||
onPin,
|
||
onMove,
|
||
onDrop,
|
||
}: {
|
||
g: { id: string; label: string; icon?: string; items: Session[] };
|
||
collapsed: boolean;
|
||
pinned: boolean;
|
||
onToggle: () => void;
|
||
onPin?: () => void;
|
||
onMove?: (dir: -1 | 1) => void;
|
||
onDrop?: (dragged: string, after: boolean) => void;
|
||
}) {
|
||
const t = useT();
|
||
const [menu, setMenu] = useState(false);
|
||
const [over, setOver] = useState<"before" | "after">();
|
||
useEffect(() => {
|
||
if (!menu) return;
|
||
const close = () => setMenu(false);
|
||
window.addEventListener("click", close);
|
||
window.addEventListener("keydown", close);
|
||
return () => {
|
||
window.removeEventListener("click", close);
|
||
window.removeEventListener("keydown", close);
|
||
};
|
||
}, [menu]);
|
||
const canMove = !!onMove;
|
||
return (
|
||
<div
|
||
className={`session-row group-row ${menu ? "menu-open" : ""} ${over ? `drop-${over}` : ""}`}
|
||
draggable={canMove}
|
||
onDragStart={(e) => {
|
||
if (!canMove) return;
|
||
e.dataTransfer.setData(DRAG_TYPE, g.id);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
}}
|
||
onDragOver={(e) => {
|
||
if (!onDrop || !e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||
e.preventDefault();
|
||
const r = e.currentTarget.getBoundingClientRect();
|
||
setOver(e.clientY > r.top + r.height / 2 ? "after" : "before");
|
||
}}
|
||
onDragLeave={() => setOver(undefined)}
|
||
onDrop={(e) => {
|
||
const id = e.dataTransfer.getData(DRAG_TYPE);
|
||
const after = over === "after";
|
||
setOver(undefined);
|
||
if (id && id !== g.id && onDrop) {
|
||
e.preventDefault();
|
||
onDrop(id, after);
|
||
}
|
||
}}
|
||
>
|
||
<button className="session-group" onClick={onToggle} aria-expanded={!collapsed}>
|
||
<Icon name={collapsed ? "chevron-right" : "chevron-down"} className="tiny" />
|
||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||
<span className="grow">{g.label}</span>
|
||
{pinned && <Icon name="pin" className="tiny group-pin" />}
|
||
{collapsed && <span className="count">{g.items.length}</span>}
|
||
</button>
|
||
{canMove && (
|
||
<button
|
||
className="session-more ghost"
|
||
aria-label={t("sidebar.groupMenu")}
|
||
aria-haspopup="menu"
|
||
aria-expanded={menu}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setMenu(!menu);
|
||
}}
|
||
>
|
||
<Icon name="more" />
|
||
</button>
|
||
)}
|
||
{menu && (
|
||
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
||
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
||
</button>
|
||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
|
||
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
|
||
</button>
|
||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
|
||
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
|
||
const t = useT();
|
||
const sessions = useStore((s) => s.sessions);
|
||
const busy = useStore((s) => s.busy);
|
||
const permissions = useStore((s) => s.permissions);
|
||
const questions = useStore((s) => s.questions);
|
||
const me = useStore((s) => s.me);
|
||
const update = useStore((s) => s.update);
|
||
// Full-text search over titles and messages.
|
||
const [query, setQuery] = useState("");
|
||
const [hits, setHits] = useState<{ id: string; title: string; cwd: string; agent: string; updated_at: number; snippet: string }[]>();
|
||
useEffect(() => {
|
||
const q = query.trim();
|
||
if (q.length < 2) {
|
||
setHits(undefined);
|
||
return;
|
||
}
|
||
let alive = true;
|
||
const timer = setTimeout(() => {
|
||
get<typeof hits>(`/sessions/search?q=${encodeURIComponent(q)}`)
|
||
.then((h) => alive && setHits(h ?? []))
|
||
.catch(() => alive && setHits([]));
|
||
}, 250);
|
||
return () => {
|
||
alive = false;
|
||
clearTimeout(timer);
|
||
};
|
||
}, [query]);
|
||
|
||
const attention = useMemo(() => {
|
||
const ids = new Set<string>();
|
||
for (const p of permissions) ids.add(p.root_session_id ?? p.session_id);
|
||
for (const q of questions) ids.add(q.root_session_id ?? q.session_id);
|
||
return ids;
|
||
}, [permissions, questions]);
|
||
|
||
const [groupBy, setGroupBy] = useState<"time" | "project">(() => (localStorage.getItem(GROUP_BY) === "project" ? "project" : "time"));
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(JSON.parse(localStorage.getItem(COLLAPSED) ?? '["archive"]')));
|
||
const toggle = (id: string) =>
|
||
setCollapsed((c) => {
|
||
const n = new Set(c);
|
||
if (n.has(id)) n.delete(id);
|
||
else n.add(id);
|
||
localStorage.setItem(COLLAPSED, JSON.stringify([...n]));
|
||
return n;
|
||
});
|
||
const switchGroupBy = () => {
|
||
const next = groupBy === "time" ? "project" : "time";
|
||
localStorage.setItem(GROUP_BY, next);
|
||
setGroupBy(next);
|
||
};
|
||
|
||
const groups = useMemo(() => {
|
||
const out: { id: string; label: string; icon?: string; items: Session[] }[] = [];
|
||
const add = (id: string, label: string, s: Session, icon?: string) => {
|
||
const g = out.find((x) => x.id === id);
|
||
if (g) g.items.push(s);
|
||
else out.push({ id, label, icon, items: [s] });
|
||
};
|
||
const live = sessions.filter((s) => !s.archived);
|
||
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
|
||
const rest = live.filter((s) => !s.pinned);
|
||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])];
|
||
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
|
||
const loose = rest.filter((s) => !s.folder);
|
||
if (groupBy === "project") {
|
||
const byProject = new Map<string, Session[]>();
|
||
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
|
||
// Order of the projects: see `ordered` below (not by activity – a new message
|
||
// moves the chat to the top of its project, not the project).
|
||
for (const [cwd, items] of byProject) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||
} else {
|
||
for (const s of loose) {
|
||
const key = groupKey(s.updated_at);
|
||
add(key, t(key), s);
|
||
}
|
||
}
|
||
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
|
||
return out;
|
||
}, [sessions, groupBy, t]);
|
||
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
|
||
// (new folder or project) start at the top and are then kept where they are.
|
||
const [layout, setLayout] = useState<Layout>();
|
||
useEffect(() => {
|
||
get<Layout>("/account/sidebar")
|
||
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
|
||
.catch(() => setLayout({ order: [], pinned: [] }));
|
||
}, []);
|
||
const ordered = useMemo(() => {
|
||
const l = layout ?? { order: [], pinned: [] };
|
||
const head = groups.filter((g) => g.id === "pinned");
|
||
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
|
||
const rank = (id: string) => l.order.indexOf(id);
|
||
const mid = groups
|
||
.filter((g) => movable(g.id))
|
||
.sort((a, b) => {
|
||
const pa = l.pinned.includes(a.id);
|
||
const pb = l.pinned.includes(b.id);
|
||
if (pa !== pb) return pa ? -1 : 1;
|
||
const ra = rank(a.id);
|
||
const rb = rank(b.id);
|
||
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
|
||
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
|
||
return ra - rb;
|
||
});
|
||
return { head, mid, tail };
|
||
}, [groups, layout]);
|
||
const saveLayout = (next: Layout) => {
|
||
// Forget groups that no longer exist (no chat in that folder or project).
|
||
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
|
||
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
|
||
setLayout(clean);
|
||
void put("/account/sidebar", clean).catch((e) => toast(e.message));
|
||
};
|
||
// Freeze the position of groups that appeared since the last visit.
|
||
useEffect(() => {
|
||
if (!layout) return;
|
||
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
|
||
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [ordered, layout]);
|
||
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
|
||
const reorder = (ids: string[]) => {
|
||
if (!layout) return;
|
||
const shown = new Set(ids);
|
||
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||
};
|
||
const moveGroup = (id: string, dir: -1 | 1) => {
|
||
if (!layout) return;
|
||
const ids = ordered.mid.map((g) => g.id);
|
||
const i = ids.indexOf(id);
|
||
const j = i + dir;
|
||
// Only within the pinned or the unpinned part.
|
||
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
|
||
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||
reorder(ids);
|
||
};
|
||
const dropGroup = (dragged: string, target: string, after: boolean) => {
|
||
if (!layout || !movable(dragged)) return;
|
||
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
|
||
const at = ids.indexOf(target);
|
||
if (at < 0) return;
|
||
ids.splice(after ? at + 1 : at, 0, dragged);
|
||
// Dropped among pinned groups: pinned too (and the other way round).
|
||
const pinTarget = layout.pinned.includes(target);
|
||
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
|
||
const shown = new Set(ids);
|
||
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||
};
|
||
const pinGroup = (id: string) => {
|
||
if (!layout) return;
|
||
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
|
||
saveLayout({ ...layout, pinned });
|
||
};
|
||
|
||
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
|
||
|
||
return (
|
||
<aside className={`sidebar ${open ? "open" : ""}`}>
|
||
<div className="sidebar-head">
|
||
<div className="logo">
|
||
<img src="/icon.svg" alt="" />
|
||
CodeCraft
|
||
</div>
|
||
<StatusLeds />
|
||
</div>
|
||
<div style={{ padding: "0 12px 8px" }}>
|
||
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||
+ {t("sidebar.newSession")}
|
||
</button>
|
||
</div>
|
||
<div className="session-search">
|
||
<input
|
||
type="search"
|
||
value={query}
|
||
placeholder={t("sidebar.search")}
|
||
aria-label={t("sidebar.search")}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
|
||
/>
|
||
</div>
|
||
<nav className="session-list">
|
||
{hits && (
|
||
<>
|
||
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
||
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
||
{hits.map((h) => (
|
||
<button key={h.id} className={`session-item ${activeIds.includes(h.id) ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||
<div className="title">
|
||
<span>{h.title || t("session.untitled")}</span>
|
||
</div>
|
||
{h.snippet && <div className="sub snippet">{h.snippet}</div>}
|
||
<div className="sub">
|
||
{basename(h.cwd)} · {h.agent} · {fmtRelative(h.updated_at)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{!hits && sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
|
||
{!hits && sessions.length > 0 && (
|
||
<div className="session-view">
|
||
<button className="ghost small" onClick={switchGroupBy} title={t("sidebar.groupByHint")}>
|
||
<Icon name={groupBy === "time" ? "clock" : "folders"} className="tiny" />
|
||
{t(groupBy === "time" ? "sidebar.byTime" : "sidebar.byProject")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!hits &&
|
||
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
|
||
<div key={g.id}>
|
||
<GroupHeader
|
||
g={g}
|
||
collapsed={collapsed.has(g.id)}
|
||
pinned={!!layout?.pinned.includes(g.id)}
|
||
onToggle={() => toggle(g.id)}
|
||
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
|
||
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
|
||
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
|
||
/>
|
||
{!collapsed.has(g.id) &&
|
||
g.items.map((s) => (
|
||
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</nav>
|
||
{update && (
|
||
<a className="update-chip" href={update.url} target="_blank" rel="noreferrer" title={update.notes}>
|
||
<Icon name="sparkle" /> {t("update.available", { version: update.version })}
|
||
</a>
|
||
)}
|
||
<div className="sidebar-user">
|
||
<span title={me?.username}>
|
||
{me?.display_name || me?.username}
|
||
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
|
||
</span>
|
||
<ThemeModeButton />
|
||
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
||
<Icon name="logout" className="nav-icon" />
|
||
{t("nav.logout")}
|
||
</button>
|
||
</div>
|
||
<div className="sidebar-foot">
|
||
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
|
||
<Icon name="folder" className="nav-icon" />
|
||
{t("nav.files")}
|
||
</button>
|
||
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
|
||
<Icon name="image" className="nav-icon" />
|
||
{t("nav.images")}
|
||
</button>
|
||
<button className={`ghost ${section === "terminal" ? "active" : ""}`} onClick={() => navigate("/terminal")}>
|
||
<Icon name="terminal" className="nav-icon" />
|
||
{t("nav.terminal")}
|
||
</button>
|
||
<button className={`ghost ${section === "jobs" ? "active" : ""}`} onClick={() => navigate("/jobs")}>
|
||
<Icon name="clock" className="nav-icon" />
|
||
{t("nav.jobs")}
|
||
</button>
|
||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||
<Icon name="chart" className="nav-icon" />
|
||
{t("nav.stats")}
|
||
</button>
|
||
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
|
||
<Icon name="wrench" className="nav-icon" />
|
||
{t("nav.settings")}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
/** Quick light/dark/auto switcher. */
|
||
function ThemeModeButton() {
|
||
const t = useT();
|
||
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
||
const icon = <Icon name={mode === "auto" ? "theme-auto" : mode === "light" ? "sun" : "moon"} />;
|
||
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
|
||
return (
|
||
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
|
||
{icon}
|
||
</button>
|
||
);
|
||
}
|