.
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 { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
||||
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
|
||||
const STATUS_STYLE: Record<AccessStatus, { label: string; cls: string }> = {
|
||||
pending: { label: "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" },
|
||||
accepted: { label: "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" },
|
||||
const STATUS_STYLE: Record<AccessStatus, { cls: string }> = {
|
||||
pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
|
||||
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
|
||||
accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
|
||||
refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
||||
};
|
||||
|
||||
type Filter = "all" | AccessStatus;
|
||||
@@ -21,6 +22,7 @@ export function AccessRequestsPanel() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { t } = useT();
|
||||
|
||||
async function refresh(select?: string) {
|
||||
try {
|
||||
@@ -28,7 +30,7 @@ export function AccessRequestsPanel() {
|
||||
setRequests(res.requests);
|
||||
if (select) setSelectedId(select);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Load failed");
|
||||
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||
} finally {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{f} · {n}
|
||||
{t(`access.filter.${f}` as StringKey)} · {n}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
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"
|
||||
title="Refresh"
|
||||
aria-label="Refresh requests"
|
||||
title={t("access.refresh")}
|
||||
aria-label={t("access.refreshAria")}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
</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>}
|
||||
{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 && (
|
||||
<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} />
|
||||
{pendingCount > 0 ? "Nothing with this status." : "No access requests. The wishlist is empty."}
|
||||
{pendingCount > 0 ? t("access.emptyFiltered") : t("access.empty")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -98,10 +100,10 @@ export function AccessRequestsPanel() {
|
||||
</span>
|
||||
<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-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 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>
|
||||
</button>
|
||||
{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 { api } from "../lib/api.ts";
|
||||
import { Button, CopyButton, FlowerMark, Spinner } from "./ui.tsx";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
@@ -13,6 +14,7 @@ type State =
|
||||
|
||||
/** Public one-time key reset page: /reset/:token */
|
||||
export function ResetPage({ token }: { token: string }) {
|
||||
const { t } = useT();
|
||||
const [state, setState] = useState<State>({ kind: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,7 +29,7 @@ export function ResetPage({ token }: { token: string }) {
|
||||
const r = await api.resetConsume(token);
|
||||
setState({ kind: "done", key: r.key });
|
||||
} 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="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" />
|
||||
{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" && (
|
||||
<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="text-sm opacity-70">This reset link is unknown, already used, or expired. Request a new one from the login page.</p>
|
||||
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
|
||||
<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">{t("reset.invalidSub")}</p>
|
||||
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
|
||||
</div>
|
||||
)}
|
||||
{state.kind === "ready" && (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold">Reset key for @{state.username}?</h1>
|
||||
<p className="text-sm opacity-70">This issues a fresh access key immediately. Your old key stops working.</p>
|
||||
<h1 className="text-xl font-bold">{t("reset.readyTitle", { user: state.username })}</h1>
|
||||
<p className="text-sm opacity-70">{t("reset.readySub")}</p>
|
||||
<Button variant="gradient" size="lg" className="w-full" onClick={consume}>
|
||||
<KeyRound size={16} /> Issue new key
|
||||
<KeyRound size={16} /> {t("reset.issue")}
|
||||
</Button>
|
||||
</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" && (
|
||||
<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>
|
||||
<p className="text-sm opacity-70">Copy it now — it won't be shown again. Then log in with it.</p>
|
||||
<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">{t("reset.doneSub")}</p>
|
||||
<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>
|
||||
<CopyButton text={state.key} />
|
||||
</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>
|
||||
)}
|
||||
{state.kind === "error" && (
|
||||
<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>
|
||||
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
|
||||
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,23 @@ import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, Clock, MailWarning, MessageSquareText, Search, SendHorizontal, XCircle } from "lucide-react";
|
||||
import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts";
|
||||
import { Button, CopyButton, Field, FlowerMark, Input, Modal, Spinner } from "./ui.tsx";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
|
||||
const STATUS_STYLE: Record<AccessRequest["status"], { label: string; cls: string; icon: typeof Clock }> = {
|
||||
pending: { label: "Pending review", 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 },
|
||||
accepted: { label: "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 },
|
||||
const STATUS_STYLE: Record<AccessRequest["status"], { cls: string; icon: typeof Clock }> = {
|
||||
pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock },
|
||||
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search },
|
||||
accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", icon: CheckCircle2 },
|
||||
refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400", icon: XCircle },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: AccessRequest["status"] }) {
|
||||
const { t } = useT();
|
||||
const s = STATUS_STYLE[status];
|
||||
return (
|
||||
<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.label}
|
||||
{t(`review.status.${status}` as StringKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +30,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const { t } = useT();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -59,7 +62,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
setMessages((prev) => [...prev, res.message]);
|
||||
setDraft("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Send failed");
|
||||
setError(err instanceof Error ? err.message : t("access.sendFailed");
|
||||
} finally {
|
||||
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="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" />
|
||||
<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 && (
|
||||
<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="text-sm opacity-70">This link is unknown. Check the URL from your email, or file a new request from the login page.</p>
|
||||
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
|
||||
<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">{t("review.notFoundSub")}</p>
|
||||
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -91,12 +94,12 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
</div>
|
||||
{request.reason && (
|
||||
<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>
|
||||
)}
|
||||
{request.status === "accepted" && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -109,7 +112,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
? "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",
|
||||
)}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +121,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
|
||||
{closed ? (
|
||||
<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>
|
||||
) : (
|
||||
<form onSubmit={send} className="space-y-2">
|
||||
@@ -127,14 +130,14 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
rows={2}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Argue your case…"
|
||||
aria-label="Reply to your ticket"
|
||||
placeholder={t("review.replyPh")}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{sending ? <Spinner size={16} /> : <SendHorizontal size={16} />}
|
||||
@@ -142,7 +145,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<p className="text-center text-xs opacity-60">
|
||||
Email notifications are one-way — always reply here, not by email.
|
||||
{t("review.mailHint")}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
@@ -160,6 +163,7 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { t } = useT();
|
||||
const [done, setDone] = useState<{ reviewUrl: string; username: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -185,48 +189,46 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
||||
});
|
||||
setDone({ reviewUrl: res.reviewUrl, username: res.request.username });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Request failed");
|
||||
setError(err instanceof Error ? err.message : t("dlg.reqFailed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Request access" icon={MessageSquareText}>
|
||||
<Modal open={open} onClose={onClose} title={t("review.requestTitle")} icon={MessageSquareText}>
|
||||
{done ? (
|
||||
<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">
|
||||
<CheckCircle2 size={18} /> Request received
|
||||
<CheckCircle2 size={18} /> {t("review.received")}
|
||||
</p>
|
||||
<p className="text-sm opacity-70">
|
||||
@{done.username} is reserved for you while an admin reviews it.
|
||||
A confirmation email with your personal ticket link is on its way.
|
||||
{t("review.reserved", { user: done.username })}
|
||||
</p>
|
||||
<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>
|
||||
<CopyButton text={done.reviewUrl} label="Copy link" />
|
||||
<CopyButton text={done.reviewUrl} label={t("review.copyLink")} />
|
||||
</div>
|
||||
<Button size="sm" className="w-full" onClick={() => (window.location.href = done.reviewUrl.replace(window.location.origin, ""))}>
|
||||
Open my ticket
|
||||
{t("review.openTicket")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<p className="text-sm opacity-70">
|
||||
Reserve a username and tell us why you want in. An admin reviews every request —
|
||||
you follow the discussion on your personal ticket page and by email.
|
||||
{t("review.requestIntro")}
|
||||
</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} />
|
||||
</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 />
|
||||
</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
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="I want to…"
|
||||
placeholder={t("review.whyPh")}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
@@ -236,8 +238,8 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
||||
</Field>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Send request"}</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} /> : t("review.sendRequest")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { api } from "../lib/api.ts";
|
||||
import type { SharedChat } from "../lib/types.ts";
|
||||
import { AssistantAvatar, Avatar, FlowerMark } from "./ui.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
export function SharePage({ publicId }: { publicId: string }) {
|
||||
const { t } = useT();
|
||||
const [chat, setChat] = useState<SharedChat | null>(null);
|
||||
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">
|
||||
<FlowerMark size={36} dynamic={false} />
|
||||
<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 && (
|
||||
<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()}</>}
|
||||
</p>
|
||||
)}
|
||||
</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">
|
||||
Open KisAssistant
|
||||
{t("share.open")}
|
||||
</a>
|
||||
</div>
|
||||
</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">
|
||||
<CloudOff size={28} />
|
||||
</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">
|
||||
The link is invalid, expired, or the author unshared it.
|
||||
{t("share.goneSub")}
|
||||
{error && error.toLowerCase() !== "not found" ? ` (${error})` : ""}
|
||||
</p>
|
||||
<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">
|
||||
Open KisAssistant
|
||||
{t("share.open")}
|
||||
</a>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Go back
|
||||
{t("share.back")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowerMark size={28} dynamic={false} className="mx-auto mt-6 opacity-40" />
|
||||
</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) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
{m.role === "user"
|
||||
|
||||
@@ -358,11 +358,22 @@ export function SettingsModal({
|
||||
const [theme, setTheme] = useState("auto");
|
||||
const [busy, setBusy] = useState(false);
|
||||
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 [dragOver, setDragOver] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
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.
|
||||
// Searching filters the category list and jumps to the first match.
|
||||
@@ -391,10 +402,23 @@ export function SettingsModal({
|
||||
setError("");
|
||||
setUploadError("");
|
||||
setSearch("");
|
||||
discardStaged();
|
||||
} else {
|
||||
// Modal closed (Save, Cancel, Escape, backdrop): drop the staged file
|
||||
// so the next open previews the saved avatar again.
|
||||
discardStaged();
|
||||
}
|
||||
}, [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 (f.size > 5 * 1024 * 1024) {
|
||||
setUploadError(t("sec.avatarTooBig"));
|
||||
@@ -404,19 +428,12 @@ export function SettingsModal({
|
||||
setUploadError(t("sec.avatarType"));
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
try {
|
||||
const res = await api.uploadAvatar(user.id, f);
|
||||
setAvatarUrl(res.url); // applied when you press Save
|
||||
// Same URL path every upload -> bump the CDN version so every <Avatar>
|
||||
// preview (this modal, sidebar, chat bubbles) re-fetches immediately.
|
||||
bumpCdnVersion();
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : t("attach.uploadFailed"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
if (stagedUrlRef.current) URL.revokeObjectURL(stagedUrlRef.current);
|
||||
const url = URL.createObjectURL(f);
|
||||
stagedUrlRef.current = url;
|
||||
setStagedFile(f);
|
||||
setStagedUrl(url);
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
@@ -425,7 +442,24 @@ export function SettingsModal({
|
||||
setBusy(true);
|
||||
setError("");
|
||||
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);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -484,11 +518,22 @@ export function SettingsModal({
|
||||
{activeCat === "profile" && (
|
||||
<SettingsPane>
|
||||
<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">
|
||||
<p className="truncate text-sm font-medium">{displayName || "…"}</p>
|
||||
<p className="truncate text-xs opacity-60">@{user?.username}</p>
|
||||
</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>
|
||||
<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();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) uploadFile(f);
|
||||
if (f) stageFile(f);
|
||||
}}
|
||||
className={cn(
|
||||
"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">
|
||||
{uploading ? <Spinner size={17} /> : <ImagePlus size={17} />}
|
||||
<ImagePlus size={17} />
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
@@ -528,7 +573,7 @@ export function SettingsModal({
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) uploadFile(f);
|
||||
if (f) stageFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user