feat(i18n): translate remaining components (HU/EN/DE)
Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel, OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime helper are now fully translated in Hungarian, English and German, each with its own locale area file (auto-loaded). Key parity verified across all three languages.
This commit is contained in:
parent
941fb7d756
commit
ea317c0009
45 changed files with 1522 additions and 355 deletions
|
|
@ -1,4 +1,5 @@
|
|||
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, User, UserPlus, X } from "lucide-react";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
|
|
@ -15,11 +16,11 @@ import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
|||
import Tooltip from "./Tooltip";
|
||||
|
||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||
const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [
|
||||
{ id: "appearance", label: "Appearance", icon: Monitor },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "sync", label: "Sync", icon: RefreshCw },
|
||||
{ id: "account", label: "Account", icon: User },
|
||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||
{ id: "appearance", icon: Monitor },
|
||||
{ id: "notifications", icon: Bell },
|
||||
{ id: "sync", icon: RefreshCw },
|
||||
{ id: "account", icon: User },
|
||||
];
|
||||
|
||||
export default function SettingsPanel({
|
||||
|
|
@ -39,6 +40,7 @@ export default function SettingsPanel({
|
|||
onClose: () => void;
|
||||
onOpenWizard: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<TabId>("appearance");
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
|
|
@ -71,7 +73,7 @@ export default function SettingsPanel({
|
|||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 h-14 border-b border-border/60 shrink-0">
|
||||
<div className="text-base font-semibold">Settings</div>
|
||||
<div className="text-base font-semibold">{t("settings.title")}</div>
|
||||
<button
|
||||
onClick={close}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
||||
|
|
@ -83,20 +85,20 @@ export default function SettingsPanel({
|
|||
<div className="flex flex-1 min-h-0">
|
||||
{/* 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((t) => {
|
||||
const active = tab === t.id;
|
||||
{TABS.map((tabItem) => {
|
||||
const active = tab === tabItem.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-4 h-4 shrink-0" />
|
||||
{t.label}
|
||||
<tabItem.icon className="w-4 h-4 shrink-0" />
|
||||
{t(`settings.tabs.${tabItem.id}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -105,19 +107,19 @@ export default function SettingsPanel({
|
|||
{/* 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 overflow-y-auto">
|
||||
{TABS.map((t) => (
|
||||
{TABS.map((tabItem) => (
|
||||
<div
|
||||
key={t.id}
|
||||
key={tabItem.id}
|
||||
className={`[grid-area:1/1] p-4 ${
|
||||
tab === t.id ? "" : "invisible pointer-events-none"
|
||||
tab === tabItem.id ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{t.id === "appearance" && (
|
||||
{tabItem.id === "appearance" && (
|
||||
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
|
||||
)}
|
||||
{t.id === "notifications" && <Notifications />}
|
||||
{t.id === "sync" && <Sync me={me} />}
|
||||
{t.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
{tabItem.id === "notifications" && <Notifications />}
|
||||
{tabItem.id === "sync" && <Sync me={me} />}
|
||||
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -190,6 +192,7 @@ function Appearance({
|
|||
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);
|
||||
|
||||
|
|
@ -208,7 +211,7 @@ function Appearance({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Section title="Color scheme">
|
||||
<Section title={t("settings.appearance.colorScheme")}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{SCHEMES.map((s) => (
|
||||
<Tooltip key={s.id} hint={s.name}>
|
||||
|
|
@ -224,31 +227,31 @@ function Appearance({
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Display">
|
||||
<Row label="Dark mode">
|
||||
<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="List view" hint="Show the feed as a compact list instead of a grid of cards.">
|
||||
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
|
||||
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
||||
</Row>
|
||||
<Row
|
||||
label="Performance mode"
|
||||
hint="Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines."
|
||||
label={t("settings.appearance.performanceMode")}
|
||||
hint={t("settings.appearance.performanceModeHint")}
|
||||
>
|
||||
<Switch checked={perf} onChange={togglePerf} />
|
||||
</Row>
|
||||
<Row
|
||||
label="Show hints"
|
||||
hint="These little hover explanations. Turn them off once you know your way around."
|
||||
label={t("settings.appearance.showHints")}
|
||||
hint={t("settings.appearance.showHintsHint")}
|
||||
>
|
||||
<Switch checked={hints} onChange={toggleHints} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="Text size">
|
||||
<Section title={t("settings.appearance.textSize")}>
|
||||
<input
|
||||
type="range"
|
||||
min={0.9}
|
||||
|
|
@ -265,6 +268,7 @@ function Appearance({
|
|||
}
|
||||
|
||||
function Notifications() {
|
||||
const { t } = useTranslation();
|
||||
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
|
||||
function update(p: Partial<NotifSettings>) {
|
||||
const next = { ...cfg, ...p };
|
||||
|
|
@ -275,23 +279,23 @@ function Notifications() {
|
|||
const autoOn = cfg.durationMs > 0;
|
||||
|
||||
return (
|
||||
<Section title="Notifications">
|
||||
<Section title={t("settings.notifications.title")}>
|
||||
<Row
|
||||
label="Sound on alerts"
|
||||
hint="Play a short tone when a notification needs your attention (e.g. errors, prompts)."
|
||||
label={t("settings.notifications.sound")}
|
||||
hint={t("settings.notifications.soundHint")}
|
||||
>
|
||||
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
||||
</Row>
|
||||
<Row
|
||||
label="Auto-dismiss toasts"
|
||||
hint="When off, pop-up toasts stay until you close them. When on, they fade after the set time."
|
||||
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">Dismiss after</span>
|
||||
<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
|
||||
|
|
@ -309,20 +313,21 @@ function Notifications() {
|
|||
onClick={() =>
|
||||
notify({
|
||||
level: "info",
|
||||
title: "Test notification",
|
||||
message: "This is what a notification looks like.",
|
||||
title: t("settings.notifications.testTitle"),
|
||||
message: t("settings.notifications.testMessage"),
|
||||
sound: true,
|
||||
})
|
||||
}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
Send a test notification
|
||||
{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 });
|
||||
|
|
@ -332,9 +337,12 @@ function Sync({ me }: { me: Me }) {
|
|||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: "Sync failed" }),
|
||||
onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }),
|
||||
});
|
||||
const pauseResume = useMutation({
|
||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||
|
|
@ -347,69 +355,69 @@ function Sync({ me }: { me: Me }) {
|
|||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||
notify({
|
||||
level: "success",
|
||||
message: `Full history requested for ${r.updated ?? 0} channels`,
|
||||
message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: "Couldn't request full history" }),
|
||||
onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title="My sync status">
|
||||
<Section title={t("settings.sync.myStatus")}>
|
||||
{s ? (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<Row label="Channels" hint="How many channels you're subscribed to.">
|
||||
<Row label={t("settings.sync.channels")} hint={t("settings.sync.channelsHint")}>
|
||||
{s.channels_total}
|
||||
</Row>
|
||||
<Row
|
||||
label="Recent synced"
|
||||
hint="Channels whose recent uploads (~last year) are already in the local database, so they show in your feed."
|
||||
label={t("settings.sync.recentSynced")}
|
||||
hint={t("settings.sync.recentSyncedHint")}
|
||||
>
|
||||
{`${s.channels_recent_synced}/${s.channels_total}`}
|
||||
</Row>
|
||||
<Row
|
||||
label="Full history"
|
||||
hint="Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page)."
|
||||
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="Full history ETA"
|
||||
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
|
||||
label={t("settings.sync.fullHistoryEta")}
|
||||
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
||||
>
|
||||
{formatEta(s.deep_eta_seconds)}
|
||||
</Row>
|
||||
)}
|
||||
<Row label="My videos" hint="Total videos available to you across your subscribed channels.">
|
||||
<Row label={t("settings.sync.myVideos")} hint={t("settings.sync.myVideosHint")}>
|
||||
{s.my_videos.toLocaleString()}
|
||||
</Row>
|
||||
<Row
|
||||
label="Quota left today"
|
||||
hint="Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it."
|
||||
label={t("settings.sync.quotaLeft")}
|
||||
hint={t("settings.sync.quotaLeftHint")}
|
||||
>
|
||||
{s.quota_remaining_today.toLocaleString()}
|
||||
</Row>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted text-sm">Loading…</div>
|
||||
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Your API usage">
|
||||
<Section title={t("settings.sync.apiUsage")}>
|
||||
{usage.data ? (
|
||||
<>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<Row label="Today" hint="YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.">
|
||||
<Row label={t("settings.sync.today")} hint={t("settings.sync.todayHint")}>
|
||||
{usage.data.today.toLocaleString()}
|
||||
</Row>
|
||||
<Row label="Last 7 days">{usage.data.last_7d.toLocaleString()}</Row>
|
||||
<Row label="Last 30 days">{usage.data.last_30d.toLocaleString()}</Row>
|
||||
<Row label="All time">{usage.data.all_time.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">By action (30d)</div>
|
||||
<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]) => (
|
||||
|
|
@ -422,42 +430,44 @@ function Sync({ me }: { me: Me }) {
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted text-sm">No usage yet.</div>
|
||||
<div className="text-muted text-sm">{t("settings.sync.noUsage")}</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Actions">
|
||||
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
|
||||
<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" : ""}`} />
|
||||
Sync subscriptions
|
||||
{t("settings.sync.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip hint="Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.">
|
||||
<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" : ""}`} />
|
||||
Backfill everything
|
||||
{t("settings.sync.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
|
||||
{me.role === "admin" && s && (
|
||||
<Section title="Admin">
|
||||
<Tooltip hint="Pause or resume the background sync for the whole app (all users).">
|
||||
<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 ? "Resume background sync" : "Pause background sync"}
|
||||
{s.paused
|
||||
? t("settings.sync.resumeBackgroundSync")
|
||||
: t("settings.sync.pauseBackgroundSync")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
|
|
@ -479,6 +489,7 @@ function AccessRow({
|
|||
enableHint: string;
|
||||
onEnable: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
|
|
@ -489,15 +500,15 @@ function AccessRow({
|
|||
</div>
|
||||
{granted ? (
|
||||
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
Granted
|
||||
{t("settings.account.granted")}
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.">
|
||||
<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"
|
||||
>
|
||||
Enable
|
||||
{t("settings.account.enable")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -506,9 +517,10 @@ function AccessRow({
|
|||
}
|
||||
|
||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Section title="Account">
|
||||
<Section title={t("settings.account.title")}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Avatar
|
||||
src={me.avatar_url}
|
||||
|
|
@ -523,26 +535,25 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="YouTube access">
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
Sign-in only shares your name and email. YouTube access is granted separately, below —
|
||||
each is optional and revocable anytime in your Google Account.
|
||||
{t("settings.account.youtubeAccessIntro")}
|
||||
</p>
|
||||
<div className="divide-y divide-border">
|
||||
<AccessRow
|
||||
title="Read subscriptions (your feed)"
|
||||
title={t("settings.account.readTitle")}
|
||||
granted={me.can_read}
|
||||
grantedHint="Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this."
|
||||
enableHint="Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it."
|
||||
grantedHint={t("settings.account.readGrantedHint")}
|
||||
enableHint={t("settings.account.readEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=read";
|
||||
}}
|
||||
/>
|
||||
<AccessRow
|
||||
title="Unsubscribe (write)"
|
||||
title={t("settings.account.writeTitle")}
|
||||
granted={me.can_write}
|
||||
grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to."
|
||||
enableHint="Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
|
||||
grantedHint={t("settings.account.writeGrantedHint")}
|
||||
enableHint={t("settings.account.writeEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=write";
|
||||
}}
|
||||
|
|
@ -552,7 +563,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||
onClick={onOpenWizard}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
Walk me through setup
|
||||
{t("settings.account.walkMeThrough")}
|
||||
</button>
|
||||
</Section>
|
||||
{me.role === "admin" && <AdminInvites />}
|
||||
|
|
@ -561,6 +572,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||
}
|
||||
|
||||
function AdminInvites() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
|
|
@ -572,23 +584,23 @@ function AdminInvites() {
|
|||
mutationFn: (id: number) => api.approveInvite(id),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
notify({ level: "success", message: "Approved — they can sign in now" });
|
||||
notify({ level: "success", message: t("settings.invites.approved") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: "Approve failed" }),
|
||||
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: "Deny failed" }),
|
||||
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: "Added to the whitelist" });
|
||||
notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: "Couldn't add that email" }),
|
||||
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
|
||||
});
|
||||
|
||||
const list = invites.data ?? [];
|
||||
|
|
@ -596,9 +608,9 @@ function AdminInvites() {
|
|||
const decided = list.filter((i) => i.status !== "pending");
|
||||
|
||||
return (
|
||||
<Section title="Access requests">
|
||||
<Section title={t("settings.invites.title")}>
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-sm text-muted">No pending requests.</p>
|
||||
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{pending.map((i) => (
|
||||
|
|
@ -624,21 +636,21 @@ function AdminInvites() {
|
|||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder="Add an email directly…"
|
||||
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" /> Add
|
||||
<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">
|
||||
{decided.length} decided
|
||||
{t("settings.invites.decided", { count: decided.length })}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
{decided.map((i) => (
|
||||
|
|
@ -667,32 +679,35 @@ function InviteRow({
|
|||
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">
|
||||
requested {new Date(inv.requested_at).toLocaleString()}
|
||||
{t("settings.invites.requested", {
|
||||
time: new Date(inv.requested_at).toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint="Approve — whitelist this email and email them they're in.">
|
||||
<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="Approve"
|
||||
aria-label={t("settings.invites.approve")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip hint="Deny this request.">
|
||||
<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="Deny"
|
||||
aria-label={t("settings.invites.deny")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue