c2e5c1d9dc
- /photo full-screen slideshow (crossfade, blurred backdrop or fill, shuffle, date) - admin Photos tab: add shared-album links (check, sync now, status/stale warnings), slideshow options, browser-resized uploads - 6-hourly album sync scraping the share page (Library API no longer exposes user albums), caches 1920px + 64px copies in /data/photos - /api/health/photos -> 503 when an album has not synced for 48h - history/restore covers photo settings; min admin password length 6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
798 lines
34 KiB
TypeScript
798 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
type Tone = "cyan" | "amber" | "violet" | "emerald" | "rose";
|
|
const TONES: Tone[] = ["cyan", "amber", "violet", "emerald", "rose"];
|
|
const TONE_COLOR: Record<Tone, string> = {
|
|
cyan: "#67e8f9",
|
|
amber: "#fbbf24",
|
|
violet: "#a78bfa",
|
|
emerald: "#34d399",
|
|
rose: "#fb7185",
|
|
};
|
|
|
|
type SavedCalendar = { id: string; name: string; tone: Tone; enabled: boolean; masked: string };
|
|
type TestResult = { ok: boolean; sourceName?: string; eventCount?: number; upcoming?: number; error?: string };
|
|
type DraftCalendar = {
|
|
key: string;
|
|
id?: string;
|
|
name: string;
|
|
tone: Tone;
|
|
enabled: boolean;
|
|
masked?: string;
|
|
newUrl: string;
|
|
replacing: boolean;
|
|
test?: TestResult | "running";
|
|
};
|
|
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string };
|
|
type Tab = "calendars" | "photos" | "account";
|
|
|
|
async function api<T>(path: string, init?: RequestInit & { body?: string }) {
|
|
const response = await fetch(path, {
|
|
...init,
|
|
credentials: "same-origin",
|
|
cache: "no-store",
|
|
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
|
|
});
|
|
const data = (await response.json().catch(() => ({}))) as T & { error?: string };
|
|
return { status: response.status, ok: response.ok, data };
|
|
}
|
|
|
|
function toDraft(calendar: SavedCalendar): DraftCalendar {
|
|
return { key: calendar.id, ...calendar, newUrl: "", replacing: false };
|
|
}
|
|
|
|
function formatWhen(iso: string) {
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime()) || date.getTime() === 0) return "initial setup";
|
|
return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
}
|
|
|
|
const inputClass =
|
|
"w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-cyan-300/60 focus:outline-none";
|
|
const buttonClass =
|
|
"rounded-lg px-3 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-40";
|
|
const primaryButton = `${buttonClass} bg-cyan-300 text-slate-950 hover:bg-cyan-200`;
|
|
const ghostButton = `${buttonClass} border border-white/10 bg-white/5 text-slate-200 hover:bg-white/10`;
|
|
const dangerButton = `${buttonClass} border border-rose-400/30 bg-rose-400/10 text-rose-200 hover:bg-rose-400/20`;
|
|
|
|
function PasswordForm({ mode, onDone }: { mode: "setup" | "login"; onDone: () => void }) {
|
|
const [password, setPassword] = useState("");
|
|
const [confirm, setConfirm] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const submit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
setError("");
|
|
if (mode === "setup" && password !== confirm) {
|
|
setError("The two passwords don't match.");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
const result = await api<{ ok?: boolean }>(`/api/admin/${mode}`, { method: "POST", body: JSON.stringify({ password }) });
|
|
setBusy(false);
|
|
if (result.ok) onDone();
|
|
else setError(result.data.error || "Something went wrong.");
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={submit} className="mx-auto mt-16 max-w-sm space-y-4 rounded-2xl border border-white/10 bg-[#0c121d] p-6">
|
|
<div>
|
|
<h1 className="text-lg font-bold">{mode === "setup" ? "Set the admin password" : "Organizer admin"}</h1>
|
|
<p className="mt-1 text-sm text-slate-400">
|
|
{mode === "setup"
|
|
? "No password exists yet. Choose one now. You'll need it to change calendars and photos."
|
|
: "Sign in to manage calendars and photos."}
|
|
</p>
|
|
</div>
|
|
<input
|
|
className={inputClass}
|
|
type="password"
|
|
autoComplete={mode === "setup" ? "new-password" : "current-password"}
|
|
placeholder="Password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
autoFocus
|
|
/>
|
|
{mode === "setup" && (
|
|
<input
|
|
className={inputClass}
|
|
type="password"
|
|
autoComplete="new-password"
|
|
placeholder="Confirm password"
|
|
value={confirm}
|
|
onChange={(event) => setConfirm(event.target.value)}
|
|
/>
|
|
)}
|
|
{error && <p className="text-sm text-rose-300">{error}</p>}
|
|
<button className={`${primaryButton} w-full`} disabled={busy || password.length < 1}>
|
|
{busy ? "…" : mode === "setup" ? "Save password" : "Sign in"}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function TestBadge({ test }: { test?: TestResult | "running" }) {
|
|
if (!test) return null;
|
|
if (test === "running") return <span className="text-xs text-slate-400">Testing…</span>;
|
|
if (!test.ok) return <span className="text-xs text-rose-300">✕ {test.error || "Failed"}</span>;
|
|
return (
|
|
<span className="text-xs text-emerald-300">
|
|
✓ {test.sourceName} · {test.upcoming} upcoming
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CalendarsTab() {
|
|
const [drafts, setDrafts] = useState<DraftCalendar[]>([]);
|
|
const [saved, setSaved] = useState<SavedCalendar[]>([]);
|
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
|
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [loaded, setLoaded] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
const [calendars, versions] = await Promise.all([
|
|
api<{ calendars: SavedCalendar[] }>("/api/admin/calendars"),
|
|
api<{ history: HistoryEntry[] }>("/api/admin/history"),
|
|
]);
|
|
if (calendars.ok) {
|
|
setSaved(calendars.data.calendars);
|
|
setDrafts(calendars.data.calendars.map(toDraft));
|
|
}
|
|
if (versions.ok) setHistory(versions.data.history);
|
|
setLoaded(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
|
|
void load();
|
|
}, [load]);
|
|
|
|
const update = (key: string, patch: Partial<DraftCalendar>) =>
|
|
setDrafts((current) => current.map((draft) => (draft.key === key ? { ...draft, ...patch } : draft)));
|
|
|
|
const dirty =
|
|
drafts.length !== saved.length ||
|
|
drafts.some((draft, index) => {
|
|
const original = saved[index];
|
|
return (
|
|
!original ||
|
|
draft.id !== original.id ||
|
|
draft.name !== original.name ||
|
|
draft.tone !== original.tone ||
|
|
draft.enabled !== original.enabled ||
|
|
draft.newUrl.trim() !== ""
|
|
);
|
|
});
|
|
|
|
const addCalendar = () =>
|
|
setDrafts((current) => [
|
|
...current,
|
|
{
|
|
key: `new-${Date.now()}`,
|
|
name: "",
|
|
tone: TONES[current.length % TONES.length],
|
|
enabled: true,
|
|
newUrl: "",
|
|
replacing: true,
|
|
},
|
|
]);
|
|
|
|
const move = (index: number, delta: number) =>
|
|
setDrafts((current) => {
|
|
const next = [...current];
|
|
const target = index + delta;
|
|
if (target < 0 || target >= next.length) return current;
|
|
[next[index], next[target]] = [next[target], next[index]];
|
|
return next;
|
|
});
|
|
|
|
const test = async (draft: DraftCalendar) => {
|
|
update(draft.key, { test: "running" });
|
|
const body = draft.newUrl.trim() ? { url: draft.newUrl.trim() } : { id: draft.id };
|
|
const result = await api<TestResult>("/api/admin/calendars/test", { method: "POST", body: JSON.stringify(body) });
|
|
update(draft.key, { test: result.data.ok !== undefined ? result.data : { ok: false, error: result.data.error } });
|
|
};
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
setMessage(null);
|
|
const result = await api<{ calendars: SavedCalendar[] }>("/api/admin/calendars", {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
calendars: drafts.map((draft) => ({
|
|
id: draft.id,
|
|
name: draft.name,
|
|
tone: draft.tone,
|
|
enabled: draft.enabled,
|
|
...(draft.newUrl.trim() ? { url: draft.newUrl.trim() } : {}),
|
|
})),
|
|
}),
|
|
});
|
|
setBusy(false);
|
|
if (result.ok) {
|
|
setMessage({ tone: "ok", text: "Saved. The TVs pick up calendar changes within about 5 minutes." });
|
|
await load();
|
|
} else {
|
|
setMessage({ tone: "error", text: result.data.error || "Save failed." });
|
|
}
|
|
};
|
|
|
|
const revert = async (entry: HistoryEntry) => {
|
|
if (!window.confirm(`Restore the calendars as they were on ${formatWhen(entry.savedAt)}? The current list is kept in history.`)) return;
|
|
const result = await api<{ ok: boolean }>("/api/admin/history", { method: "POST", body: JSON.stringify({ id: entry.id }) });
|
|
setMessage(result.ok ? { tone: "ok", text: "Restored." } : { tone: "error", text: result.data.error || "Restore failed." });
|
|
await load();
|
|
};
|
|
|
|
if (!loaded) return <p className="text-sm text-slate-400">Loading…</p>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<section className="space-y-3">
|
|
{drafts.length === 0 && <p className="text-sm text-slate-400">No calendars yet. Add one below.</p>}
|
|
{drafts.map((draft, index) => (
|
|
<article key={draft.key} className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
|
|
<div className="flex items-center gap-3">
|
|
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: TONE_COLOR[draft.tone] }} />
|
|
<input
|
|
className={inputClass}
|
|
placeholder="Name (blank = use the calendar's own name)"
|
|
value={draft.name}
|
|
maxLength={50}
|
|
onChange={(event) => update(draft.key, { name: event.target.value })}
|
|
/>
|
|
<label className="flex shrink-0 items-center gap-2 text-xs text-slate-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.enabled}
|
|
onChange={(event) => update(draft.key, { enabled: event.target.checked })}
|
|
/>
|
|
Shown
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-xs text-slate-400">Color</span>
|
|
{TONES.map((tone) => (
|
|
<button
|
|
key={tone}
|
|
type="button"
|
|
aria-label={tone}
|
|
onClick={() => update(draft.key, { tone })}
|
|
className={`h-6 w-6 rounded-full border-2 ${draft.tone === tone ? "border-white" : "border-transparent"}`}
|
|
style={{ background: TONE_COLOR[tone] }}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{draft.id && !draft.replacing ? (
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
|
<code className="rounded bg-white/5 px-2 py-1 text-slate-300">{draft.masked}</code>
|
|
<button type="button" className="text-cyan-300 underline-offset-2 hover:underline" onClick={() => update(draft.key, { replacing: true })}>
|
|
Replace address
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<input
|
|
className={inputClass}
|
|
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
value={draft.newUrl}
|
|
onChange={(event) => update(draft.key, { newUrl: event.target.value, test: undefined })}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button type="button" className={ghostButton} onClick={() => test(draft)} disabled={!draft.id && !draft.newUrl.trim()}>
|
|
Test
|
|
</button>
|
|
<button type="button" className={ghostButton} onClick={() => move(index, -1)} disabled={index === 0} aria-label="Move up">
|
|
↑
|
|
</button>
|
|
<button type="button" className={ghostButton} onClick={() => move(index, 1)} disabled={index === drafts.length - 1} aria-label="Move down">
|
|
↓
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={dangerButton}
|
|
onClick={() => setDrafts((current) => current.filter((entry) => entry.key !== draft.key))}
|
|
>
|
|
Remove
|
|
</button>
|
|
<TestBadge test={draft.test} />
|
|
</div>
|
|
</article>
|
|
))}
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button type="button" className={ghostButton} onClick={addCalendar} disabled={drafts.length >= 10}>
|
|
+ Add calendar
|
|
</button>
|
|
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
|
|
{busy ? "Saving…" : "Save changes"}
|
|
</button>
|
|
{dirty && (
|
|
<button type="button" className={ghostButton} onClick={() => setDrafts(saved.map(toDraft))}>
|
|
Discard
|
|
</button>
|
|
)}
|
|
</div>
|
|
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
|
|
<p className="text-xs leading-relaxed text-slate-500">
|
|
In Google Calendar: Settings → pick the calendar → <em>Integrate calendar</em> → copy the{" "}
|
|
<em>Secret address in iCal format</em>. Saved addresses are never shown again. They work like passwords.
|
|
</p>
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">History (calendars and photo settings)</h2>
|
|
{history.length === 0 ? (
|
|
<p className="text-sm text-slate-500">No earlier versions yet.</p>
|
|
) : (
|
|
<ul className="divide-y divide-white/5 rounded-xl border border-white/10 bg-[#0c121d]">
|
|
{history.map((entry) => (
|
|
<li key={entry.id} className="flex items-center justify-between gap-3 px-4 py-2 text-sm">
|
|
<span className="text-slate-300">
|
|
{formatWhen(entry.savedAt)} <span className="text-slate-500">· {entry.calendarCount} calendar(s), {entry.albumCount ?? 0} album(s)</span>
|
|
</span>
|
|
<button type="button" className={ghostButton} onClick={() => revert(entry)}>
|
|
Restore
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type AlbumView = {
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
masked: string;
|
|
title: string;
|
|
photoCount: number;
|
|
lastSuccessAt: number | null;
|
|
lastAttemptAt: number;
|
|
lastError: string;
|
|
truncated: boolean;
|
|
stale: boolean;
|
|
syncing: boolean;
|
|
};
|
|
type PhotoDisplay = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean };
|
|
type UploadView = { key: string; thumb: string; src: string };
|
|
type PhotosView = { display: PhotoDisplay; albums: AlbumView[]; uploads: UploadView[] };
|
|
type AlbumDraft = { key: string; id?: string; name: string; enabled: boolean; shareUrl: string; view?: AlbumView; check?: string };
|
|
|
|
function ago(timestamp: number | null) {
|
|
if (!timestamp) return "never";
|
|
const minutes = Math.round((Date.now() - timestamp) / 60_000);
|
|
if (minutes < 1) return "just now";
|
|
if (minutes < 60) return `${minutes} min ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
|
|
}
|
|
|
|
/** Resize in the browser to a 1920px JPEG and a 64px thumbnail, so the server needs no image library. */
|
|
async function resizeForUpload(file: File) {
|
|
const bitmap = await createImageBitmap(file);
|
|
const encode = async (edge: number, quality: number) => {
|
|
const scale = Math.min(1, edge / Math.max(bitmap.width, bitmap.height));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
|
|
if (!blob) throw new Error("Could not encode the photo");
|
|
return { blob, width: canvas.width, height: canvas.height };
|
|
};
|
|
const photo = await encode(1920, 0.88);
|
|
const thumb = await encode(64, 0.7);
|
|
bitmap.close();
|
|
return { photo, thumb };
|
|
}
|
|
|
|
function PhotosTab() {
|
|
const [view, setView] = useState<PhotosView | null>(null);
|
|
const [display, setDisplay] = useState<PhotoDisplay | null>(null);
|
|
const [albums, setAlbums] = useState<AlbumDraft[]>([]);
|
|
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [uploading, setUploading] = useState("");
|
|
|
|
const apply = useCallback((next: PhotosView) => {
|
|
setView(next);
|
|
setDisplay(next.display);
|
|
setAlbums(next.albums.map((album) => ({ key: album.id, id: album.id, name: album.name, enabled: album.enabled, shareUrl: "", view: album })));
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
const result = await api<PhotosView>("/api/admin/photos");
|
|
if (result.ok) apply(result.data);
|
|
}, [apply]);
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
|
|
void load();
|
|
}, [load]);
|
|
|
|
// While anything is syncing, refresh the status every few seconds.
|
|
const anySyncing = view?.albums.some((album) => album.syncing) ?? false;
|
|
useEffect(() => {
|
|
if (!anySyncing) return;
|
|
const timer = window.setInterval(async () => {
|
|
const result = await api<PhotosView>("/api/admin/photos");
|
|
if (result.ok) setView(result.data);
|
|
}, 4000);
|
|
return () => window.clearInterval(timer);
|
|
}, [anySyncing]);
|
|
|
|
const update = (key: string, patch: Partial<AlbumDraft>) =>
|
|
setAlbums((current) => current.map((album) => (album.key === key ? { ...album, ...patch } : album)));
|
|
|
|
const dirty =
|
|
!!view &&
|
|
!!display &&
|
|
(JSON.stringify(display) !== JSON.stringify(view.display) ||
|
|
albums.length !== view.albums.length ||
|
|
albums.some((album, index) => {
|
|
const original = view.albums[index];
|
|
return !original || album.id !== original.id || album.name !== original.name || album.enabled !== original.enabled || album.shareUrl.trim() !== "";
|
|
}));
|
|
|
|
const check = async (draft: AlbumDraft) => {
|
|
update(draft.key, { check: "Checking…" });
|
|
const result = await api<{ ok: boolean; title?: string; photos?: number; videos?: number; truncated?: boolean; error?: string }>(
|
|
"/api/admin/photos/check",
|
|
{ method: "POST", body: JSON.stringify({ shareUrl: draft.shareUrl }) },
|
|
);
|
|
const data = result.data;
|
|
update(draft.key, {
|
|
check: data.ok
|
|
? `✓ "${data.title}": ${data.photos} photos${data.videos ? `, ${data.videos} videos (skipped)` : ""}${data.truncated ? " (only the first page will sync)" : ""}`
|
|
: `✕ ${data.error || "Could not read the album"}`,
|
|
name: draft.name || (data.ok && data.title ? data.title : draft.name),
|
|
});
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!display) return;
|
|
setBusy(true);
|
|
setMessage(null);
|
|
const result = await api<PhotosView>("/api/admin/photos", {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
display,
|
|
albums: albums.map((album) => ({ id: album.id, name: album.name, enabled: album.enabled, ...(album.shareUrl.trim() ? { shareUrl: album.shareUrl.trim() } : {}) })),
|
|
}),
|
|
});
|
|
setBusy(false);
|
|
if (result.ok) {
|
|
apply(result.data);
|
|
setMessage({ tone: "ok", text: "Saved. New albums are downloading now. The TVs update within about 30 seconds." });
|
|
window.setTimeout(load, 3000);
|
|
} else setMessage({ tone: "error", text: result.data.error || "Save failed." });
|
|
};
|
|
|
|
const syncNow = async (album: AlbumView) => {
|
|
setView((current) => current && { ...current, albums: current.albums.map((entry) => (entry.id === album.id ? { ...entry, syncing: true } : entry)) });
|
|
const result = await api<{ ok: boolean; photoCount?: number; lastError?: string }>("/api/admin/photos/sync", { method: "POST", body: JSON.stringify({ id: album.id }) });
|
|
setMessage(
|
|
result.data.ok
|
|
? { tone: "ok", text: `Synced: ${result.data.photoCount} photos${result.data.lastError ? ` (${result.data.lastError})` : ""}.` }
|
|
: { tone: "error", text: result.data.lastError || result.data.error || "Sync failed." },
|
|
);
|
|
await load();
|
|
};
|
|
|
|
const upload = async (files: FileList | null) => {
|
|
if (!files?.length) return;
|
|
let done = 0;
|
|
let failed = 0;
|
|
for (const file of Array.from(files)) {
|
|
setUploading(`Uploading ${done + failed + 1} of ${files.length}…`);
|
|
try {
|
|
const { photo, thumb } = await resizeForUpload(file);
|
|
const form = new FormData();
|
|
form.append("photo", photo.blob, "photo.jpg");
|
|
form.append("thumb", thumb.blob, "thumb.jpg");
|
|
form.append("width", String(photo.width));
|
|
form.append("height", String(photo.height));
|
|
form.append("takenAt", String(file.lastModified || ""));
|
|
const response = await fetch("/api/admin/photos/upload", { method: "POST", body: form, credentials: "same-origin" });
|
|
if (response.ok) done += 1;
|
|
else failed += 1;
|
|
} catch {
|
|
failed += 1;
|
|
}
|
|
}
|
|
setUploading("");
|
|
setMessage(failed ? { tone: "error", text: `Uploaded ${done}; ${failed} failed (use JPEG, PNG or WebP photos).` } : { tone: "ok", text: `Uploaded ${done} photo(s).` });
|
|
await load();
|
|
};
|
|
|
|
const removeUpload = async (key: string) => {
|
|
await api(`/api/admin/photos/upload?key=${key}`, { method: "DELETE" });
|
|
await load();
|
|
};
|
|
|
|
if (!view || !display) return <p className="text-sm text-slate-400">Loading…</p>;
|
|
const statusFor = (draft: AlbumDraft) => view.albums.find((album) => album.id === draft.id);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<section className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">Albums</h2>
|
|
<Link href="/photo" className="text-xs text-cyan-300 hover:underline" target="_blank">
|
|
Open the photo frame ↗
|
|
</Link>
|
|
</div>
|
|
{albums.length === 0 && <p className="text-sm text-slate-400">No albums yet. Add a Google Photos shared album below.</p>}
|
|
{albums.map((draft) => {
|
|
const status = statusFor(draft);
|
|
const stale = status?.stale ?? false;
|
|
return (
|
|
<article key={draft.key} className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
className={inputClass}
|
|
placeholder="Name (blank = the album's own title)"
|
|
value={draft.name}
|
|
maxLength={60}
|
|
onChange={(event) => update(draft.key, { name: event.target.value })}
|
|
/>
|
|
<label className="flex shrink-0 items-center gap-2 text-xs text-slate-300">
|
|
<input type="checkbox" checked={draft.enabled} onChange={(event) => update(draft.key, { enabled: event.target.checked })} />
|
|
Shown
|
|
</label>
|
|
</div>
|
|
{draft.id ? (
|
|
<div className="space-y-1 text-xs">
|
|
<p className="text-slate-400">
|
|
<code className="rounded bg-white/5 px-2 py-1 text-slate-300">{status?.masked}</code>{" "}
|
|
{status?.title && <span className="text-slate-500">· “{status.title}”</span>}
|
|
</p>
|
|
<p className={status?.lastError || stale ? "text-amber-300" : "text-slate-400"}>
|
|
{status?.syncing
|
|
? "Syncing…"
|
|
: `${status?.photoCount ?? 0} photos · last synced ${ago(status?.lastSuccessAt ?? null)}${status?.truncated ? " · only the first page synced" : ""}`}
|
|
{stale && " · ⚠ not synced for over 48 hours"}
|
|
</p>
|
|
{status?.lastError && !status.syncing && <p className="text-amber-300">⚠ {status.lastError}</p>}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<input
|
|
className={inputClass}
|
|
placeholder="https://photos.app.goo.gl/…"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
value={draft.shareUrl}
|
|
onChange={(event) => update(draft.key, { shareUrl: event.target.value, check: undefined })}
|
|
/>
|
|
{draft.check && <p className={`text-xs ${draft.check.startsWith("✓") ? "text-emerald-300" : "text-slate-400"}`}>{draft.check}</p>}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
{!draft.id && (
|
|
<button type="button" className={ghostButton} onClick={() => check(draft)} disabled={!draft.shareUrl.trim()}>
|
|
Check link
|
|
</button>
|
|
)}
|
|
{status && (
|
|
<button type="button" className={ghostButton} onClick={() => syncNow(status)} disabled={status.syncing || !status.enabled}>
|
|
Sync now
|
|
</button>
|
|
)}
|
|
<button type="button" className={dangerButton} onClick={() => setAlbums((current) => current.filter((entry) => entry.key !== draft.key))}>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
<button
|
|
type="button"
|
|
className={ghostButton}
|
|
disabled={albums.length >= 10}
|
|
onClick={() => setAlbums((current) => [...current, { key: `new-${Date.now()}`, name: "", enabled: true, shareUrl: "" }])}
|
|
>
|
|
+ Add album
|
|
</button>
|
|
<p className="text-xs leading-relaxed text-slate-500">
|
|
In Google Photos, open the album → Share → <em>Create link</em>, and paste it here. Albums re-sync every 6 hours. Adding photos to the
|
|
album in Google Photos is all it takes. Google doesn't offer an official way to read albums, so this reads the share page. If Google
|
|
changes that page, syncing stops (the photos you already have keep showing) and a warning appears here.
|
|
</p>
|
|
</section>
|
|
|
|
<section className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
|
|
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">Slideshow</h2>
|
|
<label className="flex items-center justify-between gap-3 text-sm">
|
|
<span>Seconds per photo</span>
|
|
<input
|
|
type="number"
|
|
min={5}
|
|
max={3600}
|
|
className={`${inputClass} w-24`}
|
|
value={display.intervalSeconds}
|
|
onChange={(event) => setDisplay({ ...display, intervalSeconds: Number(event.target.value) })}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-between gap-3 text-sm">
|
|
<span>Fit</span>
|
|
<select className={`${inputClass} w-56`} value={display.fit} onChange={(event) => setDisplay({ ...display, fit: event.target.value as PhotoDisplay["fit"] })}>
|
|
<option value="contain-blur">Whole photo, blurred background</option>
|
|
<option value="cover">Fill the screen (crops edges)</option>
|
|
</select>
|
|
</label>
|
|
{(
|
|
[
|
|
["shuffle", "Shuffle"],
|
|
["showDate", "Show the date taken"],
|
|
["includeUploads", "Include uploaded photos"],
|
|
] as const
|
|
).map(([field, label]) => (
|
|
<label key={field} className="flex items-center justify-between gap-3 text-sm">
|
|
<span>{label}</span>
|
|
<input type="checkbox" checked={display[field]} onChange={(event) => setDisplay({ ...display, [field]: event.target.checked })} />
|
|
</label>
|
|
))}
|
|
</section>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
|
|
{busy ? "Saving…" : "Save changes"}
|
|
</button>
|
|
{dirty && (
|
|
<button type="button" className={ghostButton} onClick={() => apply(view)}>
|
|
Discard
|
|
</button>
|
|
)}
|
|
</div>
|
|
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
|
|
|
|
<section className="space-y-3">
|
|
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">Uploaded photos</h2>
|
|
<p className="text-xs text-slate-500">For photos that aren't in a Google album. They're resized on this device before upload.</p>
|
|
<label className={`${ghostButton} inline-block cursor-pointer`}>
|
|
{uploading || "Upload photos…"}
|
|
<input type="file" accept="image/*" multiple className="hidden" disabled={!!uploading} onChange={(event) => void upload(event.target.files)} />
|
|
</label>
|
|
{view.uploads.length > 0 && (
|
|
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
|
|
{view.uploads.map((photo) => (
|
|
<div key={photo.key} className="group relative aspect-square overflow-hidden rounded-lg bg-white/5">
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin cached JPEG */}
|
|
<img src={photo.src} alt="" className="h-full w-full object-cover" loading="lazy" />
|
|
<button
|
|
type="button"
|
|
onClick={() => removeUpload(photo.key)}
|
|
className="absolute right-1 top-1 rounded bg-black/70 px-1.5 text-xs text-rose-200"
|
|
aria-label="Delete photo"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
|
|
const [current, setCurrent] = useState("");
|
|
const [next, setNext] = useState("");
|
|
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
|
|
|
const change = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
const result = await api("/api/admin/password", {
|
|
method: "POST",
|
|
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
|
});
|
|
setMessage(result.ok ? { tone: "ok", text: "Password changed. Other devices were signed out." } : { tone: "error", text: result.data.error || "Failed." });
|
|
if (result.ok) {
|
|
setCurrent("");
|
|
setNext("");
|
|
}
|
|
};
|
|
|
|
const signOut = async () => {
|
|
await api("/api/admin/logout", { method: "POST" });
|
|
onSignedOut();
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<form onSubmit={change} className="max-w-sm space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
|
|
<h2 className="text-sm font-bold">Change password</h2>
|
|
<input className={inputClass} type="password" autoComplete="current-password" placeholder="Current password" value={current} onChange={(event) => setCurrent(event.target.value)} />
|
|
<input className={inputClass} type="password" autoComplete="new-password" placeholder="New password (6+ characters)" value={next} onChange={(event) => setNext(event.target.value)} />
|
|
<button className={primaryButton} disabled={!current || next.length < 6}>
|
|
Change password
|
|
</button>
|
|
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
|
|
</form>
|
|
<button type="button" className={ghostButton} onClick={signOut}>
|
|
Sign out
|
|
</button>
|
|
<p className="text-xs text-slate-500">
|
|
Forgot the password? On the server: <code>docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs</code>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AdminPage() {
|
|
const [state, setState] = useState<"loading" | "setup" | "login" | "ready" | "blocked">("loading");
|
|
const [tab, setTab] = useState<Tab>("calendars");
|
|
|
|
const check = useCallback(async () => {
|
|
const result = await api<{ setupRequired: boolean; authenticated: boolean }>("/api/admin/status");
|
|
if (result.status === 403) setState("blocked");
|
|
else if (result.data.setupRequired) setState("setup");
|
|
else setState(result.data.authenticated ? "ready" : "login");
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
|
|
void check();
|
|
const fromHash = window.location.hash.replace("#", "") as Tab;
|
|
if (["calendars", "photos", "account"].includes(fromHash)) setTab(fromHash);
|
|
}, [check]);
|
|
|
|
const selectTab = (next: Tab) => {
|
|
setTab(next);
|
|
window.history.replaceState(null, "", `#${next}`);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 overflow-y-auto bg-[#070b12] text-slate-100">
|
|
<main className="mx-auto max-w-2xl px-4 py-6">
|
|
{state === "loading" && <p className="text-sm text-slate-400">Loading…</p>}
|
|
{state === "blocked" && <p className="mt-16 text-center text-sm text-slate-400">Admin is only available on the home network.</p>}
|
|
{(state === "setup" || state === "login") && <PasswordForm mode={state} onDone={() => setState("ready")} />}
|
|
{state === "ready" && (
|
|
<>
|
|
<header className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold">Organizer admin</h1>
|
|
<Link href="/" className="text-xs text-cyan-300 hover:underline">
|
|
← Open the dashboard
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
<nav className="mb-6 flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
|
|
{(["calendars", "photos", "account"] as Tab[]).map((name) => (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
onClick={() => selectTab(name)}
|
|
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold capitalize ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
|
|
>
|
|
{name}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
{tab === "calendars" && <CalendarsTab />}
|
|
{tab === "photos" && <PhotosTab />}
|
|
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|