Login page quietly probes /auth/demo (debounced) as a valid email is typed/pasted and reloads into the app on a match — no visible button. Demo sessions default to the whole library, get a one-time shared-account warning, never see the YouTube-connect onboarding/access UI or sync actions, and admins get a demo whitelist + reset panel in Settings.
804 lines
28 KiB
TypeScript
804 lines
28 KiB
TypeScript
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react";
|
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|
import { api, type Invite, type Me } from "../lib/api";
|
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
|
import Avatar from "./Avatar";
|
|
import {
|
|
configureNotifications,
|
|
getNotifSettings,
|
|
notify,
|
|
type NotifSettings,
|
|
} from "../lib/notifications";
|
|
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
|
import Tooltip from "./Tooltip";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
|
|
type TabId = "appearance" | "notifications" | "sync" | "account";
|
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
|
{ id: "appearance", icon: Monitor },
|
|
{ id: "notifications", icon: Bell },
|
|
{ id: "sync", icon: RefreshCw },
|
|
{ id: "account", icon: User },
|
|
];
|
|
|
|
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
|
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
|
|
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
|
export default function SettingsPanel({
|
|
me,
|
|
theme,
|
|
setTheme,
|
|
view,
|
|
setView,
|
|
onOpenWizard,
|
|
}: {
|
|
me: Me;
|
|
theme: ThemePrefs;
|
|
setTheme: (t: ThemePrefs) => void;
|
|
view: "grid" | "list";
|
|
setView: (v: "grid" | "list") => void;
|
|
onOpenWizard: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [tab, setTab] = useState<TabId>("appearance");
|
|
|
|
return (
|
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
|
<div className="glass rounded-2xl overflow-hidden flex">
|
|
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
|
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
|
{TABS.map((tabItem) => {
|
|
const active = tab === tabItem.id;
|
|
return (
|
|
<button
|
|
key={tabItem.id}
|
|
onClick={() => setTab(tabItem.id)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
|
active
|
|
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
|
: "text-muted hover:text-fg hover:bg-card/60"
|
|
}`}
|
|
>
|
|
<tabItem.icon className="w-4 h-4 shrink-0" />
|
|
{t(`settings.tabs.${tabItem.id}`)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
|
|
(stable height, no jump on switch); the active one is shown on top. */}
|
|
<div className="grid flex-1 min-w-0">
|
|
{TABS.map((tabItem) => (
|
|
<div
|
|
key={tabItem.id}
|
|
className={`[grid-area:1/1] p-4 ${
|
|
tab === tabItem.id ? "" : "invisible pointer-events-none"
|
|
}`}
|
|
>
|
|
{tabItem.id === "appearance" && (
|
|
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
|
|
)}
|
|
{tabItem.id === "notifications" && <Notifications />}
|
|
{tabItem.id === "sync" && <Sync me={me} />}
|
|
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="mb-6">
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({
|
|
label,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
|
<Tooltip hint={hint ?? ""}>
|
|
<span
|
|
className={
|
|
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
|
|
}
|
|
>
|
|
{label}
|
|
</span>
|
|
</Tooltip>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(!checked)}
|
|
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
|
>
|
|
<span
|
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
|
checked ? "left-[18px]" : "left-0.5"
|
|
}`}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const PERF_KEY = "subfeed.perfMode";
|
|
|
|
function Appearance({
|
|
theme,
|
|
setTheme,
|
|
view,
|
|
setView,
|
|
}: {
|
|
theme: ThemePrefs;
|
|
setTheme: (t: ThemePrefs) => void;
|
|
view: "grid" | "list";
|
|
setView: (v: "grid" | "list") => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
|
const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
|
|
|
useEffect(() => {
|
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
|
}, [perf]);
|
|
function togglePerf(v: boolean) {
|
|
setPerf(v);
|
|
localStorage.setItem(PERF_KEY, v ? "1" : "0");
|
|
api.savePrefs({ performanceMode: v }).catch(() => {});
|
|
}
|
|
function toggleHints(v: boolean) {
|
|
setHintsEnabled(v);
|
|
api.savePrefs({ hints: v }).catch(() => {});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Section title={t("settings.appearance.colorScheme")}>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{SCHEMES.map((s) => (
|
|
<Tooltip key={s.id} hint={s.name}>
|
|
<button
|
|
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
|
className={`h-9 w-full rounded-lg border-2 transition ${
|
|
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
|
}`}
|
|
style={{ background: s.swatch }}
|
|
/>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.display")}>
|
|
<Row label={t("settings.appearance.darkMode")}>
|
|
<Switch
|
|
checked={theme.mode === "dark"}
|
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
|
/>
|
|
</Row>
|
|
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
|
|
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
|
</Row>
|
|
<Row
|
|
label={t("settings.appearance.performanceMode")}
|
|
hint={t("settings.appearance.performanceModeHint")}
|
|
>
|
|
<Switch checked={perf} onChange={togglePerf} />
|
|
</Row>
|
|
<Row
|
|
label={t("settings.appearance.showHints")}
|
|
hint={t("settings.appearance.showHintsHint")}
|
|
>
|
|
<Switch checked={hints} onChange={toggleHints} />
|
|
</Row>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.textSize")}>
|
|
<input
|
|
type="range"
|
|
min={0.9}
|
|
max={1.3}
|
|
step={0.02}
|
|
value={theme.fontScale}
|
|
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
|
|
className="w-full accent-accent"
|
|
/>
|
|
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
|
|
</Section>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Notifications() {
|
|
const { t } = useTranslation();
|
|
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
|
|
function update(p: Partial<NotifSettings>) {
|
|
const next = { ...cfg, ...p };
|
|
setCfg(next);
|
|
configureNotifications(next);
|
|
api.savePrefs({ notifications: next }).catch(() => {});
|
|
}
|
|
const autoOn = cfg.durationMs > 0;
|
|
|
|
return (
|
|
<Section title={t("settings.notifications.title")}>
|
|
<Row
|
|
label={t("settings.notifications.sound")}
|
|
hint={t("settings.notifications.soundHint")}
|
|
>
|
|
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
|
</Row>
|
|
<Row
|
|
label={t("settings.notifications.autoDismiss")}
|
|
hint={t("settings.notifications.autoDismissHint")}
|
|
>
|
|
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
|
|
</Row>
|
|
{autoOn && (
|
|
<div className="py-1.5 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
|
|
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={2000}
|
|
max={15000}
|
|
step={1000}
|
|
value={cfg.durationMs}
|
|
onChange={(e) => update({ durationMs: Number(e.target.value) })}
|
|
className="w-full accent-accent mt-1"
|
|
/>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() =>
|
|
notify({
|
|
level: "info",
|
|
title: t("settings.notifications.testTitle"),
|
|
message: t("settings.notifications.testMessage"),
|
|
sound: true,
|
|
})
|
|
}
|
|
className="mt-2 text-sm text-accent hover:underline"
|
|
>
|
|
{t("settings.notifications.sendTest")}
|
|
</button>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function Sync({ me }: { me: Me }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
|
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
|
|
const s = status.data;
|
|
const syncSubs = useMutation({
|
|
mutationFn: () => api.syncSubscriptions(),
|
|
onSuccess: (r: { subscriptions?: number }) => {
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
|
notify({
|
|
level: "success",
|
|
message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }),
|
|
});
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }),
|
|
});
|
|
const pauseResume = useMutation({
|
|
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
|
|
});
|
|
const deepAll = useMutation({
|
|
mutationFn: () => api.deepAll(true),
|
|
onSuccess: (r: { updated?: number }) => {
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
|
notify({
|
|
level: "success",
|
|
message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
|
|
});
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Section title={t("settings.sync.myStatus")}>
|
|
{s ? (
|
|
<div className="space-y-1.5 text-sm">
|
|
<Row label={t("settings.sync.channels")} hint={t("settings.sync.channelsHint")}>
|
|
{s.channels_total}
|
|
</Row>
|
|
<Row
|
|
label={t("settings.sync.recentSynced")}
|
|
hint={t("settings.sync.recentSyncedHint")}
|
|
>
|
|
{`${s.channels_recent_synced}/${s.channels_total}`}
|
|
</Row>
|
|
<Row
|
|
label={t("settings.sync.fullHistory")}
|
|
hint={t("settings.sync.fullHistoryHint")}
|
|
>
|
|
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
|
</Row>
|
|
{s.deep_pending_count > 0 && (
|
|
<Row
|
|
label={t("settings.sync.fullHistoryEta")}
|
|
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
|
>
|
|
{formatEta(s.deep_eta_seconds)}
|
|
</Row>
|
|
)}
|
|
<Row label={t("settings.sync.myVideos")} hint={t("settings.sync.myVideosHint")}>
|
|
{s.my_videos.toLocaleString()}
|
|
</Row>
|
|
<Row
|
|
label={t("settings.sync.quotaLeft")}
|
|
hint={t("settings.sync.quotaLeftHint")}
|
|
>
|
|
{s.quota_remaining_today.toLocaleString()}
|
|
</Row>
|
|
</div>
|
|
) : (
|
|
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
|
|
)}
|
|
</Section>
|
|
|
|
<Section title={t("settings.sync.apiUsage")}>
|
|
{usage.data ? (
|
|
<>
|
|
<div className="space-y-1.5 text-sm">
|
|
<Row label={t("settings.sync.today")} hint={t("settings.sync.todayHint")}>
|
|
{usage.data.today.toLocaleString()}
|
|
</Row>
|
|
<Row label={t("settings.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
|
|
<Row label={t("settings.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
|
|
<Row label={t("settings.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
|
|
</div>
|
|
{Object.keys(usage.data.by_action).length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
|
<div className="uppercase tracking-wide">{t("settings.sync.byAction")}</div>
|
|
{Object.entries(usage.data.by_action)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([action, units]) => (
|
|
<div key={action} className="flex justify-between gap-2">
|
|
<span>{quotaActionLabel(action)}</span>
|
|
<span className="tabular-nums">{units.toLocaleString()}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="text-muted text-sm">{t("settings.sync.noUsage")}</div>
|
|
)}
|
|
</Section>
|
|
|
|
{!me.is_demo && (
|
|
<Section title={t("settings.sync.actions")}>
|
|
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
|
<button
|
|
onClick={() => syncSubs.mutate()}
|
|
disabled={syncSubs.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
|
{t("settings.sync.syncSubscriptions")}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
|
<button
|
|
onClick={() => deepAll.mutate()}
|
|
disabled={deepAll.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
|
>
|
|
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
|
{t("settings.sync.backfillEverything")}
|
|
</button>
|
|
</Tooltip>
|
|
</Section>
|
|
)}
|
|
|
|
{me.role === "admin" && s && (
|
|
<Section title={t("settings.sync.admin")}>
|
|
<Tooltip hint={t("settings.sync.adminPauseHint")}>
|
|
<button
|
|
onClick={() => pauseResume.mutate(s.paused)}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
|
{s.paused
|
|
? t("settings.sync.resumeBackgroundSync")
|
|
: t("settings.sync.pauseBackgroundSync")}
|
|
</button>
|
|
</Tooltip>
|
|
</Section>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AccessRow({
|
|
title,
|
|
granted,
|
|
grantedHint,
|
|
enableHint,
|
|
onEnable,
|
|
}: {
|
|
title: string;
|
|
granted: boolean;
|
|
grantedHint: string;
|
|
enableHint: string;
|
|
onEnable: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{title}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{granted ? grantedHint : enableHint}
|
|
</p>
|
|
</div>
|
|
{granted ? (
|
|
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
|
{t("settings.account.granted")}
|
|
</span>
|
|
) : (
|
|
<Tooltip hint={t("settings.account.enableHint")}>
|
|
<button
|
|
onClick={onEnable}
|
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
>
|
|
{t("settings.account.enable")}
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<>
|
|
<Section title={t("settings.account.title")}>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<Avatar
|
|
src={me.avatar_url}
|
|
fallback={me.display_name ?? me.email}
|
|
className="w-12 h-12 rounded-full"
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
|
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
|
<div className="text-xs text-muted capitalize">{me.role}</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{me.is_demo ? (
|
|
<Section title={t("settings.account.youtubeAccess")}>
|
|
<p className="text-xs text-muted leading-relaxed">
|
|
{t("settings.account.demoNotice")}
|
|
</p>
|
|
</Section>
|
|
) : (
|
|
<Section title={t("settings.account.youtubeAccess")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-1">
|
|
{t("settings.account.youtubeAccessIntro")}
|
|
</p>
|
|
<div className="divide-y divide-border">
|
|
<AccessRow
|
|
title={t("settings.account.readTitle")}
|
|
granted={me.can_read}
|
|
grantedHint={t("settings.account.readGrantedHint")}
|
|
enableHint={t("settings.account.readEnableHint")}
|
|
onEnable={() => {
|
|
window.location.href = "/auth/upgrade?access=read";
|
|
}}
|
|
/>
|
|
<AccessRow
|
|
title={t("settings.account.writeTitle")}
|
|
granted={me.can_write}
|
|
grantedHint={t("settings.account.writeGrantedHint")}
|
|
enableHint={t("settings.account.writeEnableHint")}
|
|
onEnable={() => {
|
|
window.location.href = "/auth/upgrade?access=write";
|
|
}}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={onOpenWizard}
|
|
className="mt-2 text-sm text-accent hover:underline"
|
|
>
|
|
{t("settings.account.walkMeThrough")}
|
|
</button>
|
|
</Section>
|
|
)}
|
|
{me.role === "admin" && <AdminInvites />}
|
|
{me.role === "admin" && <AdminDemo />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AdminInvites() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
|
const [newEmail, setNewEmail] = useState("");
|
|
const refresh = () => {
|
|
qc.invalidateQueries({ queryKey: ["admin-invites"] });
|
|
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
|
|
};
|
|
const approve = useMutation({
|
|
mutationFn: (id: number) => api.approveInvite(id),
|
|
onSuccess: () => {
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.approved") });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
|
|
});
|
|
const deny = useMutation({
|
|
mutationFn: (id: number) => api.denyInvite(id),
|
|
onSuccess: refresh,
|
|
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
|
|
});
|
|
const add = useMutation({
|
|
mutationFn: (email: string) => api.addInvite(email),
|
|
onSuccess: () => {
|
|
setNewEmail("");
|
|
refresh();
|
|
notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
|
|
});
|
|
|
|
const list = invites.data ?? [];
|
|
const pending = list.filter((i) => i.status === "pending");
|
|
const decided = list.filter((i) => i.status !== "pending");
|
|
|
|
return (
|
|
<Section title={t("settings.invites.title")}>
|
|
{pending.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{pending.map((i) => (
|
|
<InviteRow
|
|
key={i.id}
|
|
inv={i}
|
|
onApprove={() => approve.mutate(i.id)}
|
|
onDeny={() => deny.mutate(i.id)}
|
|
busy={approve.isPending || deny.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
|
}}
|
|
className="flex items-center gap-2 mt-3"
|
|
>
|
|
<input
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
placeholder={t("settings.invites.addPlaceholder")}
|
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
|
</button>
|
|
</form>
|
|
|
|
{decided.length > 0 && (
|
|
<details className="mt-3">
|
|
<summary className="text-xs text-muted cursor-pointer">
|
|
{t("settings.invites.decided", { count: decided.length })}
|
|
</summary>
|
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
|
{decided.map((i) => (
|
|
<div key={i.id} className="flex items-center justify-between gap-2">
|
|
<span className="truncate">{i.email}</span>
|
|
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
|
|
{i.status}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function AdminDemo() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() });
|
|
const [newEmail, setNewEmail] = useState("");
|
|
|
|
const add = useMutation({
|
|
mutationFn: (email: string) => api.addDemoWhitelist(email),
|
|
onSuccess: () => {
|
|
setNewEmail("");
|
|
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
|
|
notify({ level: "success", message: t("settings.demo.added") });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
|
|
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
|
});
|
|
const reset = useMutation({
|
|
mutationFn: () => api.resetDemo(),
|
|
onSuccess: (r: { playlists_seeded: number }) =>
|
|
notify({
|
|
level: "success",
|
|
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
|
|
}),
|
|
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
|
|
});
|
|
|
|
const rows = list.data ?? [];
|
|
|
|
return (
|
|
<Section title={t("settings.demo.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
|
|
|
{rows.length > 0 && (
|
|
<div className="space-y-1.5 mb-3">
|
|
{rows.map((r) => (
|
|
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm truncate">{r.email}</div>
|
|
{r.added_by && (
|
|
<div className="text-[11px] text-muted truncate">
|
|
{t("settings.demo.addedBy", { who: r.added_by })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("settings.demo.removeHint")}>
|
|
<button
|
|
onClick={() => remove.mutate(r.id)}
|
|
disabled={remove.isPending}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
|
aria-label={t("settings.demo.remove")}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
|
}}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<input
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
placeholder={t("settings.demo.addPlaceholder")}
|
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="mt-4 pt-3 border-t border-border">
|
|
<Tooltip hint={t("settings.demo.resetHint")}>
|
|
<button
|
|
onClick={async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("settings.demo.resetTitle"),
|
|
message: t("settings.demo.resetConfirm"),
|
|
confirmLabel: t("settings.demo.reset"),
|
|
danger: true,
|
|
})
|
|
)
|
|
reset.mutate();
|
|
}}
|
|
disabled={reset.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
|
{t("settings.demo.reset")}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function InviteRow({
|
|
inv,
|
|
onApprove,
|
|
onDeny,
|
|
busy,
|
|
}: {
|
|
inv: Invite;
|
|
onApprove: () => void;
|
|
onDeny: () => void;
|
|
busy: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm truncate">{inv.email}</div>
|
|
{inv.requested_at && (
|
|
<div className="text-[11px] text-muted">
|
|
{t("settings.invites.requested", {
|
|
time: new Date(inv.requested_at).toLocaleString(),
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("settings.invites.approveHint")}>
|
|
<button
|
|
onClick={onApprove}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.approve")}
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("settings.invites.denyHint")}>
|
|
<button
|
|
onClick={onDeny}
|
|
disabled={busy}
|
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
|
aria-label={t("settings.invites.deny")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|