siftlode/frontend/src/components/OnboardingWizard.tsx
npeter83 9c61bd898d 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.
2026-06-15 00:47:04 +02:00

260 lines
10 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
// app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is
// always an explicit, informed opt-in here.
//
// The visible step is derived from what's already granted, so the wizard resumes correctly
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
// Right after read is granted, it auto-imports the user's subscriptions so they land on a
// populated feed (channels already in the shared catalog appear instantly).
// A small reusable heads-up so the Google consent warning never feels like a surprise.
function ConsentHeadsUp() {
return (
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
<p className="text-xs leading-relaxed text-fg/80">
<Trans
i18nKey="onboarding.consent"
components={[
<span className="font-medium" />,
<span className="font-medium" />,
<span className="font-medium" />,
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
/>,
]}
/>
</p>
</div>
);
}
function Dots({ index, total }: { index: number; total: number }) {
return (
<div className="flex justify-center gap-1.5">
{Array.from({ length: total }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
}`}
/>
))}
</div>
);
}
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const importTriggered = useRef(false);
// Once we have read access, find out whether any subscriptions are imported yet.
const myStatus = useQuery({
queryKey: ["my-status"],
queryFn: api.myStatus,
enabled: me.can_read,
});
const importSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: () => {
// Feed/channels/status now have content — refresh the app behind the wizard.
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
},
});
// First time we see read access with zero imported channels, pull the user's
// subscriptions automatically so they don't have to hunt for a button.
useEffect(() => {
if (
me.can_read &&
myStatus.data &&
myStatus.data.channels_total === 0 &&
!importTriggered.current
) {
importTriggered.current = true;
importSubs.mutate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me.can_read, myStatus.data?.channels_total]);
// Closing without finishing the essential read step suppresses future auto-popups
// (it stays reachable from Settings). Once read is granted, closing is just "done".
const close = () => {
endOnboarding(!me.can_read);
onClose();
};
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me.can_read]);
// Are we currently importing (or about to)? Show a progress screen instead of the
// "you're connected" step until the first import settles.
const importing =
me.can_read &&
!importSubs.isError &&
(importSubs.isPending ||
(importTriggered.current && !importSubs.isSuccess) ||
(myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
return (
<div className="fixed inset-0 z-50 grid place-items-center p-4">
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
<button
onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
aria-label={t("onboarding.close")}
>
<X className="w-4 h-4" />
</button>
{step === "read" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
<Trans
i18nKey="onboarding.read.body"
components={[<span className="text-fg/90" />]}
/>
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("read")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
<button
onClick={close}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
{t("onboarding.read.skip")}
</button>
</div>
</>
)}
{/* Read granted: show import progress before the optional write step. */}
{me.can_read && importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
{t("onboarding.importing.body")}
</p>
</>
)}
{step === "write" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
{importSubs.isError ? (
<>{t("onboarding.write.importFailed")}</>
) : (
<>
{t("onboarding.write.feedReady", {
channels: myStatus.data
? t("onboarding.write.feedReadyChannels", { count: myStatus.data.channels_total })
: "",
})}
</>
)}
{!importSubs.isError && (
<Trans
i18nKey="onboarding.write.rationale"
components={[<span className="text-fg/90" />]}
/>
)}
</p>
{importSubs.isError && (
<button
onClick={() => importSubs.mutate()}
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
>
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
</button>
)}
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("write")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<button
onClick={() => {
endOnboarding(false);
onClose();
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
{t("onboarding.write.keepReadOnly")}
</button>
</div>
</>
)}
{step === "done" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
{t("onboarding.done.body")}
</p>
<button
onClick={() => {
endOnboarding(false);
onClose();
}}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
>
{t("onboarding.done.done")}
</button>
</>
)}
<div className="mt-6">
<Dots index={stepIndex} total={3} />
</div>
</div>
</div>
);
}