/
This commit is contained in:
@@ -120,6 +120,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { t } = useT();
|
||||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -138,7 +139,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
if (res.key) setFreshKey(res.key);
|
if (res.key) setFreshKey(res.key);
|
||||||
onChanged();
|
onChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Update failed");
|
setError(err instanceof Error ? err.message : t("access.updateFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
setDraft("");
|
setDraft("");
|
||||||
onChanged();
|
onChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Send failed");
|
setError(err instanceof Error ? err.message : t("access.sendFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
<div className="mt-2 space-y-3 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
|
<div className="mt-2 space-y-3 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
|
||||||
{freshKey && (
|
{freshKey && (
|
||||||
<div className="rounded-2xl border border-accent-500/40 bg-accent-50 p-3 dark:bg-accent-950/30">
|
<div className="rounded-2xl border border-accent-500/40 bg-accent-50 p-3 dark:bg-accent-950/30">
|
||||||
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><CheckCircle2 size={15} /> Account created — key shown once, copy it now:</p>
|
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><CheckCircle2 size={15} /> {t("access.accountCreated")}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey}</code>
|
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey}</code>
|
||||||
<CopyButton text={freshKey} />
|
<CopyButton text={freshKey} />
|
||||||
@@ -184,7 +185,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
: "rounded-tl-lg bg-white dark:bg-zinc-800",
|
: "rounded-tl-lg bg-white dark:bg-zinc-800",
|
||||||
)}>
|
)}>
|
||||||
<p className="mb-0.5 text-[11px] font-medium opacity-70">
|
<p className="mb-0.5 text-[11px] font-medium opacity-70">
|
||||||
{m.author === "admin" ? "You (admin)" : `@${request.username}`} · {new Date(m.created_at).toLocaleString()}
|
{m.author === "admin" ? t("access.youAdmin") : `@${request.username}`} · {new Date(m.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p className="whitespace-pre-wrap">{m.body}</p>
|
<p className="whitespace-pre-wrap">{m.body}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,14 +198,14 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
rows={2}
|
rows={2}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
placeholder="Reply (emailed to the requester)…"
|
placeholder={t("access.replyPh")}
|
||||||
aria-label="Reply to requester"
|
aria-label={t("access.replyAria")}
|
||||||
className="max-h-32 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-32 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() || busy}
|
disabled={!draft.trim() || busy}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{busy ? <Spinner size={15} /> : <SendHorizontal size={15} />}
|
{busy ? <Spinner size={15} /> : <SendHorizontal size={15} />}
|
||||||
@@ -214,28 +215,28 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
{!closed ? (
|
{!closed ? (
|
||||||
<div className="flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
<div className="flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
<div className="min-w-48 flex-1">
|
<div className="min-w-48 flex-1">
|
||||||
<Field label="Reason (emailed with the decision)">
|
<Field label={t("access.reasonLabel")}>
|
||||||
<Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Optional reason…" maxLength={500} />
|
<Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("access.reasonPh")} maxLength={500} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{request.status === "pending" && (
|
{request.status === "pending" && (
|
||||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("reviewing")}>
|
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("reviewing")}>
|
||||||
<Search size={14} /> Reviewing
|
<Search size={14} /> {t("access.btnReviewing")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" disabled={busy} onClick={() => setStatus("accepted")}>
|
<Button size="sm" disabled={busy} onClick={() => setStatus("accepted")}>
|
||||||
<CheckCircle2 size={14} /> Accept
|
<CheckCircle2 size={14} /> {t("access.btnAccept")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("refused")} className="!text-red-600 dark:!text-red-400">
|
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("refused")} className="!text-red-600 dark:!text-red-400">
|
||||||
<XCircle size={14} /> Refuse
|
<XCircle size={14} /> {t("access.btnRefuse")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="flex items-center gap-2 text-xs opacity-60">
|
<p className="flex items-center gap-2 text-xs opacity-60">
|
||||||
<Clock size={13} /> Ticket closed ({STATUS_STYLE[request.status].label.toLowerCase()}).
|
<Clock size={13} /> {t("access.closedNote", { status: t(`access.status.${request.status}` as StringKey).toLowerCase() })}
|
||||||
{request.reason && <> Reason: <em>{request.reason}</em></>}
|
{request.reason && <> {t("access.reasonWord")}: <em>{request.reason}</em></>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
|
import { Archive, ArchiveRestore, BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
|
||||||
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
||||||
import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
@@ -45,6 +45,8 @@ export function Chat({
|
|||||||
onModelChange,
|
onModelChange,
|
||||||
onSend,
|
onSend,
|
||||||
onShare,
|
onShare,
|
||||||
|
onUnarchive,
|
||||||
|
readOnly,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
onExpandSidebar,
|
onExpandSidebar,
|
||||||
onOpenNav,
|
onOpenNav,
|
||||||
@@ -64,6 +66,9 @@ export function Chat({
|
|||||||
onModelChange: (m: string) => void;
|
onModelChange: (m: string) => void;
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
|
/** Archived chats render read-only; the banner offers to unarchive. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
onUnarchive?: () => void;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
onExpandSidebar: () => void;
|
onExpandSidebar: () => void;
|
||||||
onOpenNav: () => void;
|
onOpenNav: () => void;
|
||||||
@@ -107,6 +112,7 @@ export function Chat({
|
|||||||
|
|
||||||
function submit(e?: React.FormEvent) {
|
function submit(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
if (readOnly) return;
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending) return;
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
|||||||
@@ -1,22 +1,58 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { 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"], { cls: string; icon: typeof Clock }> = {
|
const STATUS_STYLE: Record<
|
||||||
pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock },
|
AccessRequest["status"],
|
||||||
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search },
|
{ cls: string; icon: typeof Clock }
|
||||||
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 },
|
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"] }) {
|
function StatusBadge({ status }: { status: AccessRequest["status"] }) {
|
||||||
const { t } = useT();
|
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} />
|
||||||
{t(`review.status.${status}` as StringKey)}
|
{t(`review.status.${status}` as StringKey)}
|
||||||
</span>
|
</span>
|
||||||
@@ -62,27 +98,39 @@ 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 : t("access.sendFailed");
|
setError(err instanceof Error ? err.message : t("access.sendFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const closed = request?.status === "accepted" || request?.status === "refused";
|
const closed =
|
||||||
|
request?.status === "accepted" || request?.status === "refused";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">{t("review.title")}</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} /> {t("review.loadingTicket")}</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" /> {t("review.notFound")}</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">{t("review.notFoundSub")}</p>
|
<p className="text-sm opacity-70">{t("review.notFoundSub")}</p>
|
||||||
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
|
<Button size="sm" onClick={() => (window.location.href = "/")}>
|
||||||
|
{t("common.backToLogin")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -94,7 +142,8 @@ 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">{t("review.adminNote")} </span>{request.reason}
|
<span className="font-medium">{t("review.adminNote")} </span>
|
||||||
|
{request.reason}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{request.status === "accepted" && (
|
{request.status === "accepted" && (
|
||||||
@@ -105,14 +154,26 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
|
|
||||||
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div key={m.id} className={cn("flex", m.author === "admin" ? "justify-start" : "justify-end")}>
|
<div
|
||||||
<div className={cn(
|
key={m.id}
|
||||||
"max-w-[85%] rounded-2xl px-3.5 py-2 text-sm",
|
className={cn(
|
||||||
m.author === "admin"
|
"flex",
|
||||||
? "rounded-tl-lg bg-stone-200/70 dark:bg-zinc-800"
|
m.author === "admin" ? "justify-start" : "justify-end",
|
||||||
: "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" ? t("review.admin") : t("review.you")}</p>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] rounded-2xl px-3.5 py-2 text-sm",
|
||||||
|
m.author === "admin"
|
||||||
|
? "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"
|
||||||
|
? 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>
|
||||||
@@ -140,10 +201,18 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
aria-label={t("chat.send")}
|
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} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</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">
|
||||||
{t("review.mailHint")}
|
{t("review.mailHint")}
|
||||||
</p>
|
</p>
|
||||||
@@ -157,14 +226,23 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Wishlist form: reserve a username + email with a motivation message. */
|
/** Wishlist form: reserve a username + email with a motivation message. */
|
||||||
export function AccessRequestModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function AccessRequestModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
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 { t } = useT();
|
||||||
const [done, setDone] = useState<{ reviewUrl: string; username: string } | null>(null);
|
const [done, setDone] = useState<{
|
||||||
|
reviewUrl: string;
|
||||||
|
username: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -174,7 +252,7 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
setError("");
|
setError("");
|
||||||
setDone(null);
|
setDone(null);
|
||||||
}
|
}
|
||||||
}, [open ]);
|
}, [open]);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -189,14 +267,19 @@ 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 : t("dlg.reqFailed");
|
setError(err instanceof Error ? err.message : t("dlg.reqFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title={t("review.requestTitle")} 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">
|
||||||
@@ -206,23 +289,45 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
{t("review.reserved", { user: done.username })}
|
{t("review.reserved", { user: done.username })}
|
||||||
</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={t("review.copyLink")} />
|
<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,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
>
|
||||||
{t("review.openTicket")}
|
{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">{t("review.requestIntro")}</p>
|
||||||
{t("review.requestIntro")}
|
|
||||||
</p>
|
|
||||||
<Field label={t("common.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={t("review.emailLabel")} hint={t("review.emailHint")}>
|
<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={t("review.whyLabel")} hint={t("review.whyHint")}>
|
<Field label={t("review.whyLabel")} hint={t("review.whyHint")}>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -236,10 +341,21 @@ export function AccessRequestModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
className="max-h-60 w-full resize-y 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-60 w-full resize-y 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"
|
||||||
/>
|
/>
|
||||||
</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}>{t("common.cancel")}</Button>
|
<Button
|
||||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("review.sendRequest")}</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Archive,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
@@ -35,6 +36,7 @@ export function Sidebar({
|
|||||||
onRename,
|
onRename,
|
||||||
onTopic,
|
onTopic,
|
||||||
onShare,
|
onShare,
|
||||||
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenAdmin,
|
onOpenAdmin,
|
||||||
@@ -52,6 +54,7 @@ export function Sidebar({
|
|||||||
onRename: (conv: Conversation) => void;
|
onRename: (conv: Conversation) => void;
|
||||||
onTopic: (conv: Conversation) => void;
|
onTopic: (conv: Conversation) => void;
|
||||||
onShare: (conv: Conversation) => void;
|
onShare: (conv: Conversation) => void;
|
||||||
|
onArchive: (conv: Conversation) => void;
|
||||||
onDelete: (conv: Conversation) => void;
|
onDelete: (conv: Conversation) => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onOpenAdmin: () => void;
|
onOpenAdmin: () => void;
|
||||||
@@ -264,6 +267,7 @@ export function Sidebar({
|
|||||||
{ icon: Pencil, label: t("sidebar.rename"), onClick: () => onRename(menu.conv) },
|
{ icon: Pencil, label: t("sidebar.rename"), onClick: () => onRename(menu.conv) },
|
||||||
{ icon: Tag, label: t("sidebar.topic"), onClick: () => onTopic(menu.conv) },
|
{ icon: Tag, label: t("sidebar.topic"), onClick: () => onTopic(menu.conv) },
|
||||||
{ icon: Share2, label: t("sidebar.share"), onClick: () => onShare(menu.conv) },
|
{ icon: Share2, label: t("sidebar.share"), onClick: () => onShare(menu.conv) },
|
||||||
|
{ icon: Archive, label: t("sidebar.archive"), onClick: () => onArchive(menu.conv) },
|
||||||
{ icon: Trash2, label: t("sidebar.delete"), danger: true, onClick: () => onDelete(menu.conv) },
|
{ icon: Trash2, label: t("sidebar.delete"), danger: true, onClick: () => onDelete(menu.conv) },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ export const api = {
|
|||||||
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
|
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
|
||||||
publicShare: (publicId: string) => req<SharedChat>(`/api/share/${publicId}`, {}, false),
|
publicShare: (publicId: string) => req<SharedChat>(`/api/share/${publicId}`, {}, false),
|
||||||
|
|
||||||
|
/** Archived chats (hidden from the sidebar, managed in settings). */
|
||||||
|
archivedConvs: () => req<{ conversations: Conversation[] }>("/api/conversations/archived"),
|
||||||
|
archiveConv: (id: string) =>
|
||||||
|
req<{ conversation: Conversation }>(`/api/conversations/${id}/archive`, { method: "POST" }),
|
||||||
|
unarchiveConv: (id: string) =>
|
||||||
|
req<{ conversation: Conversation }>(`/api/conversations/${id}/unarchive`, { method: "POST" }),
|
||||||
|
|
||||||
models: (opts: { refresh?: boolean } = {}) =>
|
models: (opts: { refresh?: boolean } = {}) =>
|
||||||
req<{ models: DriverModel[] }>(`/api/models${opts.refresh ? "?refresh=1" : ""}`),
|
req<{ models: DriverModel[] }>(`/api/models${opts.refresh ? "?refresh=1" : ""}`),
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Conversation {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user