.
This commit is contained in:
@@ -2,13 +2,14 @@ import { useEffect, useState } from "react";
|
|||||||
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
|
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
|
||||||
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
||||||
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
||||||
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
const STATUS_STYLE: Record<AccessStatus, { label: string; cls: string }> = {
|
const STATUS_STYLE: Record<AccessStatus, { cls: string }> = {
|
||||||
pending: { label: "Pending", cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
|
pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
|
||||||
reviewing: { label: "Reviewing", cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
|
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
|
||||||
accepted: { label: "Accepted", cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
|
accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
|
||||||
refused: { label: "Refused", cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
||||||
};
|
};
|
||||||
|
|
||||||
type Filter = "all" | AccessStatus;
|
type Filter = "all" | AccessStatus;
|
||||||
@@ -21,6 +22,7 @@ export function AccessRequestsPanel() {
|
|||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { t } = useT();
|
||||||
|
|
||||||
async function refresh(select?: string) {
|
async function refresh(select?: string) {
|
||||||
try {
|
try {
|
||||||
@@ -28,7 +30,7 @@ export function AccessRequestsPanel() {
|
|||||||
setRequests(res.requests);
|
setRequests(res.requests);
|
||||||
if (select) setSelectedId(select);
|
if (select) setSelectedId(select);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Load failed");
|
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -57,27 +59,27 @@ export function AccessRequestsPanel() {
|
|||||||
: "bg-stone-200/70 hover:bg-stone-300/60 dark:bg-zinc-800 dark:hover:bg-zinc-700",
|
: "bg-stone-200/70 hover:bg-stone-300/60 dark:bg-zinc-800 dark:hover:bg-zinc-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{f} · {n}
|
{t(`access.filter.${f}` as StringKey)} · {n}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
onClick={() => refresh()}
|
onClick={() => refresh()}
|
||||||
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
|
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
|
||||||
title="Refresh"
|
title={t("access.refresh")}
|
||||||
aria-label="Refresh requests"
|
aria-label={t("access.refreshAria")}
|
||||||
>
|
>
|
||||||
<Undo2 size={15} />
|
<Undo2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||||
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Loading…</p>}
|
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
|
||||||
{!loading && visible.length === 0 && (
|
{!loading && visible.length === 0 && (
|
||||||
<p className="flex items-center justify-center gap-2 rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm opacity-50 dark:border-zinc-700">
|
<p className="flex items-center justify-center gap-2 rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm opacity-50 dark:border-zinc-700">
|
||||||
<Inbox size={16} />
|
<Inbox size={16} />
|
||||||
{pendingCount > 0 ? "Nothing with this status." : "No access requests. The wishlist is empty."}
|
{pendingCount > 0 ? t("access.emptyFiltered") : t("access.empty")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -98,10 +100,10 @@ export function AccessRequestsPanel() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-medium">@{r.username} <span className="font-normal opacity-50">{r.email}</span></span>
|
<span className="block truncate text-sm font-medium">@{r.username} <span className="font-normal opacity-50">{r.email}</span></span>
|
||||||
<span className="block truncate text-xs opacity-60">{r.message} · {r.message_count} message{r.message_count === 1 ? "" : "s"}</span>
|
<span className="block truncate text-xs opacity-60">{r.message} · {r.message_count === 1 ? t("access.msgOne") : t("access.msgMany", { n: r.message_count })}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
|
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
|
||||||
{STATUS_STYLE[r.status].label}
|
{t(`access.status.${r.status}` as StringKey)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
|
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { KeyRound, MailWarning, Sparkles } from "lucide-react";
|
import { KeyRound, MailWarning, Sparkles } from "lucide-react";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
import { Button, CopyButton, FlowerMark, Spinner } from "./ui.tsx";
|
import { Button, CopyButton, FlowerMark, Spinner } from "./ui.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
@@ -13,6 +14,7 @@ type State =
|
|||||||
|
|
||||||
/** Public one-time key reset page: /reset/:token */
|
/** Public one-time key reset page: /reset/:token */
|
||||||
export function ResetPage({ token }: { token: string }) {
|
export function ResetPage({ token }: { token: string }) {
|
||||||
|
const { t } = useT();
|
||||||
const [state, setState] = useState<State>({ kind: "loading" });
|
const [state, setState] = useState<State>({ kind: "loading" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,7 +29,7 @@ export function ResetPage({ token }: { token: string }) {
|
|||||||
const r = await api.resetConsume(token);
|
const r = await api.resetConsume(token);
|
||||||
setState({ kind: "done", key: r.key });
|
setState({ kind: "done", key: r.key });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({ kind: "error", message: err instanceof Error ? err.message : "Reset failed" });
|
setState({ kind: "error", message: err instanceof Error ? err.message : t("reset.failed") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,40 +37,40 @@ export function ResetPage({ token }: { token: string }) {
|
|||||||
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
|
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
|
||||||
<div className="w-full max-w-sm rounded-[2rem] border border-white/60 bg-white/80 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
|
<div className="w-full max-w-sm rounded-[2rem] border border-white/60 bg-white/80 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
|
||||||
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
|
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
|
||||||
{state.kind === "loading" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Checking link…</p>}
|
{state.kind === "loading" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("reset.checking")}</p>}
|
||||||
{state.kind === "invalid" && (
|
{state.kind === "invalid" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="flex items-center justify-center gap-2 font-semibold"><MailWarning size={18} className="text-red-500" /> Link invalid</p>
|
<p className="flex items-center justify-center gap-2 font-semibold"><MailWarning size={18} className="text-red-500" /> {t("reset.invalid")}</p>
|
||||||
<p className="text-sm opacity-70">This reset link is unknown, already used, or expired. Request a new one from the login page.</p>
|
<p className="text-sm opacity-70">{t("reset.invalidSub")}</p>
|
||||||
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
|
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{state.kind === "ready" && (
|
{state.kind === "ready" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-bold">Reset key for @{state.username}?</h1>
|
<h1 className="text-xl font-bold">{t("reset.readyTitle", { user: state.username })}</h1>
|
||||||
<p className="text-sm opacity-70">This issues a fresh access key immediately. Your old key stops working.</p>
|
<p className="text-sm opacity-70">{t("reset.readySub")}</p>
|
||||||
<Button variant="gradient" size="lg" className="w-full" onClick={consume}>
|
<Button variant="gradient" size="lg" className="w-full" onClick={consume}>
|
||||||
<KeyRound size={16} /> Issue new key
|
<KeyRound size={16} /> {t("reset.issue")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{state.kind === "working" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Issuing…</p>}
|
{state.kind === "working" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("reset.issuing")}</p>}
|
||||||
{state.kind === "done" && (
|
{state.kind === "done" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="flex items-center justify-center gap-2 text-xl font-bold"><Sparkles size={18} className="text-accent-500" /> New key ready</h1>
|
<h1 className="flex items-center justify-center gap-2 text-xl font-bold"><Sparkles size={18} className="text-accent-500" /> {t("reset.doneTitle")}</h1>
|
||||||
<p className="text-sm opacity-70">Copy it now — it won't be shown again. Then log in with it.</p>
|
<p className="text-sm opacity-70">{t("reset.doneSub")}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{state.key}</code>
|
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{state.key}</code>
|
||||||
<CopyButton text={state.key} />
|
<CopyButton text={state.key} />
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" className="w-full" onClick={() => (window.location.href = "/")}>Go to login</Button>
|
<Button size="lg" className="w-full" onClick={() => (window.location.href = "/")}>{t("reset.goLogin")}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{state.kind === "error" && (
|
{state.kind === "error" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="font-semibold text-red-600 dark:text-red-400">Reset failed</p>
|
<p className="font-semibold text-red-600 dark:text-red-400">{t("reset.failed")}</p>
|
||||||
<p className="text-sm opacity-70">{state.message}</p>
|
<p className="text-sm opacity-70">{state.message}</p>
|
||||||
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
|
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,21 +2,23 @@ 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 { 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";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
const STATUS_STYLE: Record<AccessRequest["status"], { label: string; cls: string; icon: typeof Clock }> = {
|
const STATUS_STYLE: Record<AccessRequest["status"], { cls: string; icon: typeof Clock }> = {
|
||||||
pending: { label: "Pending review", cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock },
|
pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock },
|
||||||
reviewing: { label: "Under review", cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search },
|
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search },
|
||||||
accepted: { label: "Accepted", cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", icon: CheckCircle2 },
|
accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", icon: CheckCircle2 },
|
||||||
refused: { label: "Refused", cls: "bg-red-500/10 text-red-600 dark:text-red-400", icon: XCircle },
|
refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400", icon: XCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: AccessRequest["status"] }) {
|
function StatusBadge({ status }: { status: AccessRequest["status"] }) {
|
||||||
|
const { t } = useT();
|
||||||
const s = STATUS_STYLE[status];
|
const s = STATUS_STYLE[status];
|
||||||
return (
|
return (
|
||||||
<span className={cn("inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium", s.cls)}>
|
<span className={cn("inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium", s.cls)}>
|
||||||
<s.icon size={13} />
|
<s.icon size={13} />
|
||||||
{s.label}
|
{t(`review.status.${status}` as StringKey)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [invalid, setInvalid] = useState(false);
|
const [invalid, setInvalid] = useState(false);
|
||||||
|
const { t } = useT();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
setMessages((prev) => [...prev, res.message]);
|
setMessages((prev) => [...prev, res.message]);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Send failed");
|
setError(err instanceof Error ? err.message : t("access.sendFailed");
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -71,15 +74,15 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40 sm:p-6">
|
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40 sm:p-6">
|
||||||
<div className="w-full max-w-xl rounded-[2rem] border border-white/60 bg-white/80 p-6 shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80 sm:p-8">
|
<div className="w-full max-w-xl rounded-[2rem] border border-white/60 bg-white/80 p-6 shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80 sm:p-8">
|
||||||
<FlowerMark size={48} dynamic={false} className="mx-auto mb-3" />
|
<FlowerMark size={48} dynamic={false} className="mx-auto mb-3" />
|
||||||
<h1 className="text-center font-serif text-2xl font-bold tracking-tight">Access request</h1>
|
<h1 className="text-center font-serif text-2xl font-bold tracking-tight">{t("review.title")}</h1>
|
||||||
|
|
||||||
{loading && <p className="mt-4 flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading ticket…</p>}
|
{loading && <p className="mt-4 flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("review.loadingTicket")}</p>}
|
||||||
|
|
||||||
{invalid && (
|
{invalid && (
|
||||||
<div className="mt-4 space-y-3 text-center">
|
<div className="mt-4 space-y-3 text-center">
|
||||||
<p className="flex items-center justify-center gap-2 font-semibold"><MailWarning size={18} className="text-red-500" /> Ticket not found</p>
|
<p className="flex items-center justify-center gap-2 font-semibold"><MailWarning size={18} className="text-red-500" /> {t("review.notFound")}</p>
|
||||||
<p className="text-sm opacity-70">This link is unknown. Check the URL from your email, or file a new request from the login page.</p>
|
<p className="text-sm opacity-70">{t("review.notFoundSub")}</p>
|
||||||
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
|
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -91,12 +94,12 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
{request.reason && (
|
{request.reason && (
|
||||||
<p className="rounded-2xl bg-stone-100 px-4 py-2.5 text-center text-sm dark:bg-zinc-800">
|
<p className="rounded-2xl bg-stone-100 px-4 py-2.5 text-center text-sm dark:bg-zinc-800">
|
||||||
<span className="font-medium">Admin note: </span>{request.reason}
|
<span className="font-medium">{t("review.adminNote")} </span>{request.reason}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{request.status === "accepted" && (
|
{request.status === "accepted" && (
|
||||||
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
|
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
|
||||||
Your account was created — check your email for the access key, then log in.
|
{t("review.acceptedHint")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -109,7 +112,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
? "rounded-tl-lg bg-stone-200/70 dark:bg-zinc-800"
|
? "rounded-tl-lg bg-stone-200/70 dark:bg-zinc-800"
|
||||||
: "rounded-tr-lg bg-accent-600 text-white dark:bg-accent-500 dark:text-zinc-950",
|
: "rounded-tr-lg bg-accent-600 text-white dark:bg-accent-500 dark:text-zinc-950",
|
||||||
)}>
|
)}>
|
||||||
<p className="mb-0.5 text-[11px] font-medium opacity-70">{m.author === "admin" ? "Admin" : "You"}</p>
|
<p className="mb-0.5 text-[11px] font-medium opacity-70">{m.author === "admin" ? t("review.admin") : t("review.you")}</p>
|
||||||
<p className="whitespace-pre-wrap">{m.body}</p>
|
<p className="whitespace-pre-wrap">{m.body}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +121,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
|
|
||||||
{closed ? (
|
{closed ? (
|
||||||
<p className="text-center text-xs opacity-60">
|
<p className="text-center text-xs opacity-60">
|
||||||
This ticket is closed. Replies by email are not read — if needed, file a new request.
|
{t("review.closedHint")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={send} className="space-y-2">
|
<form onSubmit={send} className="space-y-2">
|
||||||
@@ -127,14 +130,14 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
rows={2}
|
rows={2}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
placeholder="Argue your case…"
|
placeholder={t("review.replyPh")}
|
||||||
aria-label="Reply to your ticket"
|
aria-label={t("review.replyAria")}
|
||||||
className="max-h-40 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"
|
className="max-h-40 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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!draft.trim() || sending}
|
disabled={!draft.trim() || sending}
|
||||||
aria-label="Send reply"
|
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"
|
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 ? <Spinner size={16} /> : <SendHorizontal size={16} />}
|
{sending ? <Spinner size={16} /> : <SendHorizontal size={16} />}
|
||||||
@@ -142,7 +145,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
{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>}
|
||||||
<p className="text-center text-xs opacity-60">
|
<p className="text-center text-xs opacity-60">
|
||||||
Email notifications are one-way — always reply here, not by email.
|
{t("review.mailHint")}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -160,6 +163,7 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -185,48 +189,46 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
});
|
});
|
||||||
setDone({ reviewUrl: res.reviewUrl, username: res.request.username });
|
setDone({ reviewUrl: res.reviewUrl, username: res.request.username });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Request failed");
|
setError(err instanceof Error ? err.message : t("dlg.reqFailed");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title="Request access" icon={MessageSquareText}>
|
<Modal open={open} onClose={onClose} title={t("review.requestTitle")} icon={MessageSquareText}>
|
||||||
{done ? (
|
{done ? (
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<p className="flex items-center justify-center gap-2 font-semibold text-accent-700 dark:text-accent-400">
|
<p className="flex items-center justify-center gap-2 font-semibold text-accent-700 dark:text-accent-400">
|
||||||
<CheckCircle2 size={18} /> Request received
|
<CheckCircle2 size={18} /> {t("review.received")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm opacity-70">
|
<p className="text-sm opacity-70">
|
||||||
@{done.username} is reserved for you while an admin reviews it.
|
{t("review.reserved", { user: done.username })}
|
||||||
A confirmation email with your personal ticket link is on its way.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-left text-xs dark:bg-zinc-800">{done.reviewUrl}</code>
|
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-left text-xs dark:bg-zinc-800">{done.reviewUrl}</code>
|
||||||
<CopyButton text={done.reviewUrl} label="Copy link" />
|
<CopyButton text={done.reviewUrl} label={t("review.copyLink")} />
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="w-full" onClick={() => (window.location.href = done.reviewUrl.replace(window.location.origin, ""))}>
|
<Button size="sm" className="w-full" onClick={() => (window.location.href = done.reviewUrl.replace(window.location.origin, ""))}>
|
||||||
Open my ticket
|
{t("review.openTicket")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="space-y-4">
|
||||||
<p className="text-sm opacity-70">
|
<p className="text-sm opacity-70">
|
||||||
Reserve a username and tell us why you want in. An admin reviews every request —
|
{t("review.requestIntro")}
|
||||||
you follow the discussion on your personal ticket page and by email.
|
|
||||||
</p>
|
</p>
|
||||||
<Field label="Username">
|
<Field label={t("common.username")}>
|
||||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required minLength={2} maxLength={32} />
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required minLength={2} maxLength={32} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Email" hint="Required — status updates and ticket replies land here.">
|
<Field label={t("review.emailLabel")} hint={t("review.emailHint")}>
|
||||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" required />
|
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" required />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Why should you get access?" hint="10+ characters. This starts your ticket thread.">
|
<Field label={t("review.whyLabel")} hint={t("review.whyHint")}>
|
||||||
<textarea
|
<textarea
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
placeholder="I want to…"
|
placeholder={t("review.whyPh")}
|
||||||
required
|
required
|
||||||
minLength={10}
|
minLength={10}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
@@ -236,8 +238,8 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
</Field>
|
</Field>
|
||||||
{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>}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
|
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
|
||||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Send request"}</Button>
|
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("review.sendRequest")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ 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";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
|
||||||
export function SharePage({ publicId }: { publicId: string }) {
|
export function SharePage({ publicId }: { publicId: string }) {
|
||||||
|
const { t } = useT();
|
||||||
const [chat, setChat] = useState<SharedChat | null>(null);
|
const [chat, setChat] = useState<SharedChat | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -21,16 +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 ?? "Shared chat"}</p>
|
<p className="truncate font-semibold">{chat?.title ?? t("share.fallback")}</p>
|
||||||
{chat && (
|
{chat && (
|
||||||
<p className="flex items-center gap-1.5 text-xs opacity-60">
|
<p className="flex items-center gap-1.5 text-xs opacity-60">
|
||||||
<Globe size={11} /> by {chat.authorName} · {new Date(chat.sharedAt).toLocaleDateString()}
|
<Globe size={11} /> {t("share.by", { name: chat.authorName })} · {new Date(chat.sharedAt).toLocaleDateString()}
|
||||||
{chat.model && <> · {chat.model.split(":").pop()}</>}
|
{chat.model && <> · {chat.model.split(":").pop()}</>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a href="/" className="rounded-full bg-accent-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-accent-500 dark:bg-accent-500 dark:text-zinc-950">
|
<a href="/" className="rounded-full bg-accent-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-accent-500 dark:bg-accent-500 dark:text-zinc-950">
|
||||||
Open KisAssistant
|
{t("share.open")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -41,27 +43,27 @@ export function SharePage({ publicId }: { publicId: string }) {
|
|||||||
<span className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-full bg-stone-200/70 text-stone-500 dark:bg-zinc-800 dark:text-zinc-400">
|
<span className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-full bg-stone-200/70 text-stone-500 dark:bg-zinc-800 dark:text-zinc-400">
|
||||||
<CloudOff size={28} />
|
<CloudOff size={28} />
|
||||||
</span>
|
</span>
|
||||||
<h1 className="bg-gradient-to-br from-stone-900 via-stone-700 to-accent-700 bg-clip-text font-serif text-4xl font-black italic tracking-tight text-transparent dark:from-zinc-100 dark:via-zinc-300 dark:to-accent-400">This shared chat is gone</h1>
|
<h1 className="bg-gradient-to-br from-stone-900 via-stone-700 to-accent-700 bg-clip-text font-serif text-4xl font-black italic tracking-tight text-transparent dark:from-zinc-100 dark:via-zinc-300 dark:to-accent-400">{t("share.gone")}</h1>
|
||||||
<p className="mt-2 text-sm opacity-70">
|
<p className="mt-2 text-sm opacity-70">
|
||||||
The link is invalid, expired, or the author unshared it.
|
{t("share.goneSub")}
|
||||||
{error && error.toLowerCase() !== "not found" ? ` (${error})` : ""}
|
{error && error.toLowerCase() !== "not found" ? ` (${error})` : ""}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex flex-col justify-center gap-2 sm:flex-row">
|
<div className="mt-6 flex flex-col justify-center gap-2 sm:flex-row">
|
||||||
<a href="/" className="rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-accent-500 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400">
|
<a href="/" className="rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-accent-500 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400">
|
||||||
Open KisAssistant
|
{t("share.open")}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.history.length > 1 ? window.history.back() : (window.location.href = "/")}
|
onClick={() => window.history.length > 1 ? window.history.back() : (window.location.href = "/")}
|
||||||
className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-medium transition hover:bg-stone-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-medium transition hover:bg-stone-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||||
>
|
>
|
||||||
Go back
|
{t("share.back")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FlowerMark size={28} dynamic={false} className="mx-auto mt-6 opacity-40" />
|
<FlowerMark size={28} dynamic={false} className="mx-auto mt-6 opacity-40" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!chat && !error && <p className="py-16 text-center text-sm opacity-60">Loading shared chat…</p>}
|
{!chat && !error && <p className="py-16 text-center text-sm opacity-60">{t("share.loading")}</p>}
|
||||||
{chat?.messages.map((m, i) => (
|
{chat?.messages.map((m, i) => (
|
||||||
<div key={i} className="flex gap-3">
|
<div key={i} className="flex gap-3">
|
||||||
{m.role === "user"
|
{m.role === "user"
|
||||||
|
|||||||
@@ -358,11 +358,22 @@ export function SettingsModal({
|
|||||||
const [theme, setTheme] = useState("auto");
|
const [theme, setTheme] = useState("auto");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [stagedFile, setStagedFile] = useState<File | null>(null);
|
||||||
|
const [stagedUrl, setStagedUrl] = useState<string | null>(null);
|
||||||
const [uploadError, setUploadError] = useState("");
|
const [uploadError, setUploadError] = useState("");
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
// Object URLs must be revoked or they leak; the ref mirrors the latest
|
||||||
|
// staged URL so unmount/close cleanup always revokes the right one.
|
||||||
|
const stagedUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
function discardStaged() {
|
||||||
|
if (stagedUrlRef.current) URL.revokeObjectURL(stagedUrlRef.current);
|
||||||
|
stagedUrlRef.current = null;
|
||||||
|
setStagedUrl(null);
|
||||||
|
setStagedFile(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Two-column settings: categories on the left, content on the right.
|
// Two-column settings: categories on the left, content on the right.
|
||||||
// Searching filters the category list and jumps to the first match.
|
// Searching filters the category list and jumps to the first match.
|
||||||
@@ -391,10 +402,23 @@ export function SettingsModal({
|
|||||||
setError("");
|
setError("");
|
||||||
setUploadError("");
|
setUploadError("");
|
||||||
setSearch("");
|
setSearch("");
|
||||||
|
discardStaged();
|
||||||
|
} else {
|
||||||
|
// Modal closed (Save, Cancel, Escape, backdrop): drop the staged file
|
||||||
|
// so the next open previews the saved avatar again.
|
||||||
|
discardStaged();
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
async function uploadFile(f: File) {
|
// Safety net: revoke the object URL if the component unmounts mid-stage.
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (stagedUrlRef.current) URL.revokeObjectURL(stagedUrlRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Staging is preview-only: no bytes leave the browser until Save.
|
||||||
|
// (The CDN path is fixed per account, so PUTing early would publish the
|
||||||
|
// picture immediately and Cancel could never undo it.)
|
||||||
|
function stageFile(f: File) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
if (f.size > 5 * 1024 * 1024) {
|
if (f.size > 5 * 1024 * 1024) {
|
||||||
setUploadError(t("sec.avatarTooBig"));
|
setUploadError(t("sec.avatarTooBig"));
|
||||||
@@ -404,19 +428,12 @@ export function SettingsModal({
|
|||||||
setUploadError(t("sec.avatarType"));
|
setUploadError(t("sec.avatarType"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUploading(true);
|
|
||||||
setUploadError("");
|
setUploadError("");
|
||||||
try {
|
if (stagedUrlRef.current) URL.revokeObjectURL(stagedUrlRef.current);
|
||||||
const res = await api.uploadAvatar(user.id, f);
|
const url = URL.createObjectURL(f);
|
||||||
setAvatarUrl(res.url); // applied when you press Save
|
stagedUrlRef.current = url;
|
||||||
// Same URL path every upload -> bump the CDN version so every <Avatar>
|
setStagedFile(f);
|
||||||
// preview (this modal, sidebar, chat bubbles) re-fetches immediately.
|
setStagedUrl(url);
|
||||||
bumpCdnVersion();
|
|
||||||
} catch (err) {
|
|
||||||
setUploadError(err instanceof Error ? err.message : t("attach.uploadFailed"));
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e: React.FormEvent) {
|
async function save(e: React.FormEvent) {
|
||||||
@@ -425,7 +442,24 @@ export function SettingsModal({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
|
// Save is the only point that touches the network for the avatar:
|
||||||
|
// push the staged bytes first, then persist the resulting URL.
|
||||||
|
let url = avatarUrl.trim();
|
||||||
|
if (stagedFile) {
|
||||||
|
try {
|
||||||
|
const up = await api.uploadAvatar(user.id, stagedFile);
|
||||||
|
url = up.url;
|
||||||
|
// Same CDN path every upload -> bump the version so every <Avatar>
|
||||||
|
// (sidebar, chat bubbles) re-fetches the new bytes immediately.
|
||||||
|
bumpCdnVersion();
|
||||||
|
} catch (err) {
|
||||||
|
setUploadError(err instanceof Error ? err.message : t("attach.uploadFailed"));
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discardStaged();
|
||||||
|
}
|
||||||
|
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: url, email: email.trim(), theme });
|
||||||
onSaved(res.user);
|
onSaved(res.user);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -484,11 +518,22 @@ export function SettingsModal({
|
|||||||
{activeCat === "profile" && (
|
{activeCat === "profile" && (
|
||||||
<SettingsPane>
|
<SettingsPane>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar name={displayName || "?"} url={avatarUrl || undefined} size={52} />
|
<Avatar name={displayName || "?"} url={stagedUrl ?? avatarUrl ?? undefined} size={52} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{displayName || "…"}</p>
|
<p className="truncate text-sm font-medium">{displayName || "…"}</p>
|
||||||
<p className="truncate text-xs opacity-60">@{user?.username}</p>
|
<p className="truncate text-xs opacity-60">@{user?.username}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{stagedUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={discardStaged}
|
||||||
|
aria-label={t("common.cancel")}
|
||||||
|
title={t("common.cancel")}
|
||||||
|
className="rounded-full p-1.5 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-1.5 block text-sm font-medium opacity-80">{t("settings.avatarUpload")}</span>
|
<span className="mb-1.5 block text-sm font-medium opacity-80">{t("settings.avatarUpload")}</span>
|
||||||
@@ -504,7 +549,7 @@ export function SettingsModal({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const f = e.dataTransfer.files?.[0];
|
const f = e.dataTransfer.files?.[0];
|
||||||
if (f) uploadFile(f);
|
if (f) stageFile(f);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center gap-3 rounded-3xl border-2 border-dashed px-4 py-3.5 transition",
|
"flex cursor-pointer items-center gap-3 rounded-3xl border-2 border-dashed px-4 py-3.5 transition",
|
||||||
@@ -514,10 +559,10 @@ export function SettingsModal({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
|
||||||
{uploading ? <Spinner size={17} /> : <ImagePlus size={17} />}
|
<ImagePlus size={17} />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 text-sm">
|
<span className="min-w-0 flex-1 text-sm">
|
||||||
<span className="block font-medium">{uploading ? t("settings.uploading") : dragOver ? t("settings.dropActive") : t("settings.dropIdle")}</span>
|
<span className="block font-medium">{dragOver ? t("settings.dropActive") : t("settings.dropIdle")}</span>
|
||||||
<span className="block text-xs opacity-60">{t("settings.avatarHint")}</span>
|
<span className="block text-xs opacity-60">{t("settings.avatarHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,7 +573,7 @@ export function SettingsModal({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (f) uploadFile(f);
|
if (f) stageFile(f);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface ConversationRow {
|
|||||||
title: string;
|
title: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
archived_at: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
@@ -119,6 +120,11 @@ export function getDb(): Database {
|
|||||||
if (!cols.some((c) => c.name === "email")) {
|
if (!cols.some((c) => c.name === "email")) {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
|
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
|
||||||
}
|
}
|
||||||
|
// Archive flag added after the initial schema — keep idempotent.
|
||||||
|
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
|
||||||
|
if (!convCols.some((c) => c.name === "archived_at")) {
|
||||||
|
db.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
|
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
|
||||||
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
|
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
@@ -242,6 +248,7 @@ export function createConversation(userId: string, opts: { title?: string; topic
|
|||||||
title: opts.title?.slice(0, 120) || "New chat",
|
title: opts.title?.slice(0, 120) || "New chat",
|
||||||
topic: opts.topic?.slice(0, 120) || "",
|
topic: opts.topic?.slice(0, 120) || "",
|
||||||
model: opts.model?.slice(0, 160) || "",
|
model: opts.model?.slice(0, 160) || "",
|
||||||
|
archived_at: 0,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -252,7 +259,21 @@ export function createConversation(userId: string, opts: { title?: string; topic
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listConversations(userId: string): ConversationRow[] {
|
export function listConversations(userId: string): ConversationRow[] {
|
||||||
return getDb().query("SELECT * FROM conversations WHERE user_id = ? ORDER BY updated_at DESC").all(userId) as ConversationRow[];
|
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archived chats: hidden from the sidebar, managed in settings. */
|
||||||
|
export function listArchivedConversations(userId: string): ConversationRow[] {
|
||||||
|
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Un)archive a conversation. archived_at is left out of updated_at on
|
||||||
|
* purpose so an unarchived chat returns to its chronological place.
|
||||||
|
*/
|
||||||
|
export function setConversationArchived(id: string, archived: boolean): ConversationRow | null {
|
||||||
|
getDb().query("UPDATE conversations SET archived_at = ? WHERE id = ?").run(archived ? Date.now() : 0, id);
|
||||||
|
return getConversation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConversation(id: string): ConversationRow | null {
|
export function getConversation(id: string): ConversationRow | null {
|
||||||
|
|||||||
+17
-1
@@ -34,8 +34,10 @@ import {
|
|||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
isRegistrationEnabled,
|
isRegistrationEnabled,
|
||||||
listConversations,
|
listConversations,
|
||||||
|
listArchivedConversations,
|
||||||
listUsers,
|
listUsers,
|
||||||
peekReset,
|
peekReset,
|
||||||
|
setConversationArchived,
|
||||||
setSetting,
|
setSetting,
|
||||||
toPublicUser,
|
toPublicUser,
|
||||||
touchConversation,
|
touchConversation,
|
||||||
@@ -917,6 +919,11 @@ const server = Bun.serve({
|
|||||||
return json({ conversations: listConversations(user.id) });
|
return json({ conversations: listConversations(user.id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archived chats (hidden from the sidebar, managed in settings).
|
||||||
|
if (path === "/api/conversations/archived" && req.method === "GET") {
|
||||||
|
return json({ conversations: listArchivedConversations(user.id) });
|
||||||
|
}
|
||||||
|
|
||||||
// Full-text-ish search over your own chats: titles, topics AND old
|
// Full-text-ish search over your own chats: titles, topics AND old
|
||||||
// prompts/replies. Returns the matching conversations, most recent
|
// prompts/replies. Returns the matching conversations, most recent
|
||||||
// first, with a short snippet of the first hit.
|
// first, with a short snippet of the first hit.
|
||||||
@@ -928,7 +935,7 @@ const server = Bun.serve({
|
|||||||
`SELECT c.*, m.content AS hit, m.role AS hit_role FROM conversations c
|
`SELECT c.*, m.content AS hit, m.role AS hit_role FROM conversations c
|
||||||
LEFT JOIN messages m ON m.conv_id = c.id
|
LEFT JOIN messages m ON m.conv_id = c.id
|
||||||
AND m.content LIKE ? ESCAPE '\\'
|
AND m.content LIKE ? ESCAPE '\\'
|
||||||
WHERE c.user_id = ? AND (c.title LIKE ? ESCAPE '\\' OR c.topic LIKE ? ESCAPE '\\' OR m.id IS NOT NULL)
|
WHERE c.user_id = ? AND c.archived_at = 0 AND (c.title LIKE ? ESCAPE '\\' OR c.topic LIKE ? ESCAPE '\\' OR m.id IS NOT NULL)
|
||||||
GROUP BY c.id ORDER BY c.updated_at DESC LIMIT 30`,
|
GROUP BY c.id ORDER BY c.updated_at DESC LIMIT 30`,
|
||||||
).all(like, user.id, like, like) as (ConversationRow & { hit: string | null; hit_role: string | null })[];
|
).all(like, user.id, like, like) as (ConversationRow & { hit: string | null; hit_role: string | null })[];
|
||||||
return json({
|
return json({
|
||||||
@@ -959,6 +966,13 @@ const server = Bun.serve({
|
|||||||
return json({ conversation: conv }, 201);
|
return json({ conversation: conv }, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const archiveMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})\/(archive|unarchive)$/);
|
||||||
|
if (archiveMatch && req.method === "POST") {
|
||||||
|
const conv = getConversation(archiveMatch[1]!);
|
||||||
|
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
|
||||||
|
return json({ conversation: setConversationArchived(conv.id, archiveMatch[2] === "archive") });
|
||||||
|
}
|
||||||
|
|
||||||
const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/);
|
const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/);
|
||||||
if (convMatch) {
|
if (convMatch) {
|
||||||
const conv = getConversation(convMatch[1]!);
|
const conv = getConversation(convMatch[1]!);
|
||||||
@@ -1075,6 +1089,8 @@ const server = Bun.serve({
|
|||||||
if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400);
|
if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400);
|
||||||
conv = getConversation(body.conversationId);
|
conv = getConversation(body.conversationId);
|
||||||
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
|
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
|
||||||
|
// Archived chats are read-only: no new messages may be written.
|
||||||
|
if (conv.archived_at) return json({ error: "conversation is archived (read-only) — unarchive it to write again" }, 403);
|
||||||
// Persist the new user message (dedupe against last stored one).
|
// Persist the new user message (dedupe against last stored one).
|
||||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||||
const stored = getMessages(conv.id);
|
const stored = getMessages(conv.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user