E32 S4 — the burn-down from 29 skipped functions to 1: - try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into their own function (push, the setup wizard's next, the share page's unlock). - A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo does not see it: the value is computed before the try, or the try lives in a module-level helper (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times). - The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through one effect event, read once when the player is built; PlexPlayer's unmount relies on the leaving-the-item cleanup for the generation bump it duplicated. - What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias, the loadSession self-reference (declared early, set after commit); PlayerModal's session-position read during render (now where the player starts), navRef/modeRef writes, and a description helper that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default among same-filter views, so the seed needs no sticky write — test seen red on the old helper). - A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old contract test claimed the call was safe inside any `use…` function, which was false — replaced by a render test that is red under the old name. - PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer stack: removed. react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid (IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
|
|
import { api, HttpError } from "../lib/api";
|
|
import { setLanguage, type LangCode } from "../i18n";
|
|
import LanguageSwitcher from "./LanguageSwitcher";
|
|
import { Button } from "./ui/Button";
|
|
import { Input } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
|
|
// setup token comes from the URL the app printed to the container logs (?token=…); every step
|
|
// posts it back as the X-Setup-Token header. On finish the instance flips to configured, the
|
|
// wizard disappears, and the operator signs in with the admin account they just created.
|
|
type StepId = "intro" | "admin" | "google" | "smtp" | "finish";
|
|
|
|
const PASSWORD_MIN = 10;
|
|
|
|
export default function SetupWizard() {
|
|
const { t, i18n } = useTranslation();
|
|
const [token] = useState(() => new URLSearchParams(window.location.search).get("token") ?? "");
|
|
const [info, setInfo] = useState<{ secrets_manageable: boolean } | null>(null);
|
|
// "No token at all" is not a fetch outcome, it is a fact about the URL — so it is derived rather
|
|
// than pushed into state by an effect, which had to run and commit before the error could show.
|
|
const [infoFailed, setInfoFailed] = useState(false);
|
|
const tokenError = !token || infoFailed;
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
api
|
|
.setupInfo(token)
|
|
.then(setInfo)
|
|
.catch(() => setInfoFailed(true));
|
|
}, [token]);
|
|
|
|
// Step state.
|
|
const [stepIdx, setStepIdx] = useState(0);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
|
|
// and a set in one handler commit together, leaving the node untouched).
|
|
const [attempt, setAttempt] = useState(0);
|
|
// Admin account.
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
// Google OAuth (optional).
|
|
const [gid, setGid] = useState("");
|
|
const [gsecret, setGsecret] = useState("");
|
|
// SMTP (optional).
|
|
const [smtp, setSmtp] = useState({ host: "", port: "587", user: "", from: "", password: "" });
|
|
const [testTo, setTestTo] = useState("");
|
|
const [testState, setTestState] = useState<"idle" | "sending" | "ok" | "fail">("idle");
|
|
|
|
if (tokenError)
|
|
return (
|
|
<Centered>
|
|
<TokenError />
|
|
</Centered>
|
|
);
|
|
if (!info)
|
|
return (
|
|
<Centered>
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted" />
|
|
</Centered>
|
|
);
|
|
|
|
// Steps adapt to the instance: Google + SMTP need encrypted secret storage (TOKEN_ENCRYPTION_KEY).
|
|
const steps: StepId[] = [
|
|
"intro",
|
|
"admin",
|
|
...(info.secrets_manageable ? (["google", "smtp"] as StepId[]) : []),
|
|
"finish",
|
|
];
|
|
const step = steps[stepIdx];
|
|
const go = (delta: number) => {
|
|
setError("");
|
|
setStepIdx((i) => Math.min(steps.length - 1, Math.max(0, i + delta)));
|
|
};
|
|
|
|
// Each step's "Next" action persists what it collected, then advances. Optional steps with empty
|
|
// fields just advance (skip). Errors surface inline (the setup calls use the quiet flag); a step's own
|
|
// validation sets its message and stays.
|
|
async function next() {
|
|
setAttempt((n) => n + 1);
|
|
setError("");
|
|
setBusy(true);
|
|
try {
|
|
await saveStep();
|
|
} catch (e) {
|
|
setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
|
|
}
|
|
setBusy(false);
|
|
}
|
|
|
|
// next()'s steps, apart so `busy` clears on every way out without a try/finally (the React
|
|
// Compiler cannot lower one yet, and skips the whole component).
|
|
async function saveStep() {
|
|
if (step === "admin") {
|
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return setError(t("setup.admin.invalidEmail"));
|
|
if (password.length < PASSWORD_MIN)
|
|
return setError(t("setup.admin.shortPassword", { n: PASSWORD_MIN }));
|
|
await api.setupAdmin(token, email, password);
|
|
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
|
|
if (!gid.trim() || !gsecret.trim()) return setError(t("setup.google.bothRequired"));
|
|
await api.setupConfig(token, {
|
|
google_client_id: gid.trim(),
|
|
google_client_secret: gsecret.trim(),
|
|
});
|
|
} else if (step === "smtp" && smtp.host.trim()) {
|
|
await api.setupConfig(token, {
|
|
smtp_host: smtp.host.trim(),
|
|
smtp_port: Number(smtp.port) || 587,
|
|
smtp_user: smtp.user.trim(),
|
|
smtp_from: smtp.from.trim(),
|
|
smtp_password: smtp.password,
|
|
});
|
|
} else if (step === "finish") {
|
|
await api.setupFinish(token);
|
|
window.location.href = "/"; // configured now → reloads into the login page
|
|
return;
|
|
}
|
|
go(1);
|
|
}
|
|
|
|
async function sendTest() {
|
|
setTestState("sending");
|
|
const port = Number(smtp.port) || 587;
|
|
try {
|
|
// Persist the SMTP fields first so the test uses them, then send.
|
|
if (smtp.host.trim())
|
|
await api.setupConfig(token, {
|
|
smtp_host: smtp.host.trim(),
|
|
smtp_port: port,
|
|
smtp_user: smtp.user.trim(),
|
|
smtp_from: smtp.from.trim(),
|
|
smtp_password: smtp.password,
|
|
});
|
|
await api.setupTestEmail(token, testTo.trim());
|
|
setTestState("ok");
|
|
} catch {
|
|
setTestState("fail");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Centered>
|
|
<div className="w-full max-w-lg">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="text-xl font-bold tracking-tight">
|
|
Sift<span className="text-accent">lode</span>
|
|
</div>
|
|
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
|
|
</div>
|
|
|
|
{/* Step progress dots */}
|
|
<div className="flex items-center gap-1.5 mb-4">
|
|
{steps.map((s, i) => (
|
|
<div
|
|
key={s}
|
|
className={`h-1.5 flex-1 rounded-full transition ${
|
|
i <= stepIdx ? "bg-accent" : "bg-border"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className={`${SURFACE.panel} p-6`}>
|
|
{step === "intro" && (
|
|
<Section title={t("setup.intro.title")} desc={t("setup.intro.body")} />
|
|
)}
|
|
|
|
{step === "admin" && (
|
|
<Section title={t("setup.admin.title")} desc={t("setup.admin.desc")}>
|
|
<Input
|
|
className="w-full"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder={t("setup.admin.email")}
|
|
autoComplete="username"
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder={t("setup.admin.password", { n: PASSWORD_MIN })}
|
|
autoComplete="new-password"
|
|
/>
|
|
</Section>
|
|
)}
|
|
|
|
{step === "google" && (
|
|
<Section title={t("setup.google.title")} desc={t("setup.google.desc")}>
|
|
<Input
|
|
className="w-full"
|
|
value={gid}
|
|
onChange={(e) => setGid(e.target.value)}
|
|
placeholder={t("setup.google.clientId")}
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
value={gsecret}
|
|
onChange={(e) => setGsecret(e.target.value)}
|
|
placeholder={t("setup.google.clientSecret")}
|
|
/>
|
|
<p className="text-xs text-muted">{t("setup.google.skipHint")}</p>
|
|
</Section>
|
|
)}
|
|
|
|
{step === "smtp" && (
|
|
<Section title={t("setup.smtp.title")} desc={t("setup.smtp.desc")}>
|
|
<Input
|
|
className="w-full"
|
|
value={smtp.host}
|
|
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
|
|
placeholder={t("setup.smtp.host")}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
className="w-24"
|
|
value={smtp.port}
|
|
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
|
|
placeholder={t("setup.smtp.port")}
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
value={smtp.user}
|
|
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
|
|
placeholder={t("setup.smtp.user")}
|
|
/>
|
|
</div>
|
|
<Input
|
|
className="w-full"
|
|
value={smtp.from}
|
|
onChange={(e) => setSmtp({ ...smtp, from: e.target.value })}
|
|
placeholder={t("setup.smtp.from")}
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
type="password"
|
|
value={smtp.password}
|
|
onChange={(e) => setSmtp({ ...smtp, password: e.target.value })}
|
|
placeholder={t("setup.smtp.password")}
|
|
/>
|
|
{smtp.host.trim() && (
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Input
|
|
className="w-full"
|
|
value={testTo}
|
|
onChange={(e) => setTestTo(e.target.value)}
|
|
placeholder={t("setup.smtp.testTo")}
|
|
/>
|
|
<Button
|
|
variant="glass"
|
|
size="md"
|
|
className="shrink-0"
|
|
type="button"
|
|
onClick={sendTest}
|
|
disabled={testState === "sending" || !testTo.trim()}
|
|
>
|
|
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
|
|
{t("setup.smtp.test")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{testState === "ok" && (
|
|
<p className="text-xs text-success">{t("setup.smtp.testOk")}</p>
|
|
)}
|
|
{testState === "fail" && (
|
|
<p className="text-xs text-danger">{t("setup.smtp.testFail")}</p>
|
|
)}
|
|
<p className="text-xs text-muted">{t("setup.smtp.skipHint")}</p>
|
|
</Section>
|
|
)}
|
|
|
|
{step === "finish" && (
|
|
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
|
|
)}
|
|
|
|
{error && (
|
|
<p key={attempt} role="alert" className="text-sm text-danger mt-3">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between mt-6">
|
|
<Button
|
|
variant="glass"
|
|
icon={<ChevronLeft />}
|
|
onClick={() => go(-1)}
|
|
disabled={stepIdx === 0 || busy}
|
|
>
|
|
{t("setup.nav.back")}
|
|
</Button>
|
|
<Button
|
|
onClick={next}
|
|
loading={busy}
|
|
icon={step === "finish" ? <Check /> : undefined}
|
|
className="shadow-md shadow-accent/20"
|
|
>
|
|
{step === "finish" ? t("setup.nav.finish") : t("setup.nav.next")}
|
|
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
function Centered({ children }: { children: React.ReactNode }) {
|
|
return <div className="min-h-dvh bg-bg text-fg grid place-items-center p-4">{children}</div>;
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
desc,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
desc: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{title}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-1">{desc}</p>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TokenError() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className={`${SURFACE.panel} max-w-md p-6 text-center`}>
|
|
<h2 className="text-lg font-semibold">{t("setup.token.title")}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2">{t("setup.token.body")}</p>
|
|
</div>
|
|
);
|
|
}
|