feat(hobbs-organizer): v1.2.0 photo frame (/photo) with Google Photos shared albums
- /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
This commit is contained in:
@@ -1,3 +1,17 @@
|
||||
# 1.2.0 (2026-11-04)
|
||||
|
||||
- `/photo`: full-screen photo frame with crossfade, a blurred backdrop or fill mode, shuffle, and the date taken.
|
||||
- It picks up new settings and photos within about 30 seconds and reloads itself every 12 hours.
|
||||
- Admin **Photos** tab:
|
||||
- Google Photos shared albums: paste the share link, use **Check**, and **Sync now**. The tab shows sync status, stale warnings and errors.
|
||||
- Slideshow options.
|
||||
- Photo uploads. Each upload is resized in the browser to 1920px plus a 64px backdrop thumbnail.
|
||||
- Albums sync every 6 hours (and 15 seconds after start).
|
||||
- Each sync caches up to 500 photos per album at 1920px under `/data/photos`, skips videos, and removes photos deleted from the album.
|
||||
- `/api/health/photos` returns 503 when an enabled album has not synced for 48 hours.
|
||||
- History and Restore now cover photo settings too. A restore re-syncs any album it brings back.
|
||||
- The minimum admin password length is now 6.
|
||||
|
||||
# 1.1.0 (2026-11-04)
|
||||
|
||||
- Password-protected, LAN-only `/admin` screen for calendar links (masked, test, history/restore).
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.2.0
|
||||
|
||||
@@ -6,7 +6,7 @@ const scrypt = promisify(scryptCallback) as (password: string, salt: Buffer, key
|
||||
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
||||
const SESSION_COOKIE = "hfo_admin";
|
||||
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
export const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
// ---------- network policy ----------
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type PhotoAlbum, readSettings } from "./store";
|
||||
|
||||
/*
|
||||
* Google Photos shared albums. Google removed third-party read access to user albums from the
|
||||
* Library API on 2025-03-31, so this reads the public share page the same way a browser does:
|
||||
* the album's items are embedded as JSON in an AF_initDataCallback({key: 'ds:1', ...}) block.
|
||||
* This is unofficial and may break when Google changes the page. Cached photos keep showing
|
||||
* if it does, and the album reports lastError plus a stale lastSuccessAt.
|
||||
*/
|
||||
|
||||
export const MAX_PHOTOS_PER_ALBUM = 500;
|
||||
export const MAX_UPLOADS = 500;
|
||||
export const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
export const STALE_AFTER_MS = 48 * 60 * 60 * 1000;
|
||||
const PHOTO_EDGE = 1920;
|
||||
const THUMB_EDGE = 64;
|
||||
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
|
||||
const VIDEO_MARKER = "76647426";
|
||||
|
||||
export type CachedPhoto = { key: string; takenAt: number | null; width: number; height: number };
|
||||
export type AlbumIndex = {
|
||||
title: string;
|
||||
firstAttemptAt: number;
|
||||
lastAttemptAt: number;
|
||||
lastSuccessAt: number | null;
|
||||
lastError: string;
|
||||
truncated: boolean;
|
||||
photos: CachedPhoto[];
|
||||
};
|
||||
|
||||
const photosRoot = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "photos");
|
||||
export const UPLOADS_DIR = "uploads";
|
||||
const dirFor = (name: string) => path.join(photosRoot(), name);
|
||||
const indexPath = (name: string) => path.join(dirFor(name), "index.json");
|
||||
|
||||
export function validPhotoDir(name: string) {
|
||||
return name === UPLOADS_DIR || /^[A-Za-z0-9-]{8,64}$/.test(name);
|
||||
}
|
||||
export function validPhotoFile(name: string) {
|
||||
return /^[a-f0-9]{20}(\.t)?\.jpg$/.test(name);
|
||||
}
|
||||
export const photoFilePath = (dir: string, file: string) => path.join(dirFor(dir), file);
|
||||
|
||||
function emptyIndex(): AlbumIndex {
|
||||
return { title: "", firstAttemptAt: 0, lastAttemptAt: 0, lastSuccessAt: null, lastError: "", truncated: false, photos: [] };
|
||||
}
|
||||
|
||||
export async function readIndex(name: string): Promise<AlbumIndex> {
|
||||
try {
|
||||
return { ...emptyIndex(), ...(JSON.parse(await readFile(indexPath(name), "utf8")) as Partial<AlbumIndex>) };
|
||||
} catch {
|
||||
return emptyIndex();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(name: string, index: AlbumIndex) {
|
||||
await mkdir(dirFor(name), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${indexPath(name)}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify(index), { mode: 0o600 });
|
||||
await rename(temporary, indexPath(name));
|
||||
}
|
||||
|
||||
async function writeFileAtomic(file: string, data: Uint8Array) {
|
||||
const temporary = `${file}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, data, { mode: 0o600 });
|
||||
await rename(temporary, file);
|
||||
}
|
||||
|
||||
export function isJpeg(data: Uint8Array) {
|
||||
return data.length > 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
||||
}
|
||||
|
||||
async function readLimited(response: Response, maxBytes: number) {
|
||||
const declared = Number(response.headers.get("content-length") ?? 0);
|
||||
if (declared > maxBytes) throw new Error("Response too large");
|
||||
if (!response.body) return new Uint8Array();
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error("Response too large");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- scraping ----------
|
||||
|
||||
type ScrapedItem = { googleId: string; baseUrl: string; width: number; height: number; takenAt: number | null; video: boolean };
|
||||
export type ScrapedAlbum = { title: string; items: ScrapedItem[]; hasMorePages: boolean };
|
||||
|
||||
const ALLOWED_PAGE_HOSTS = new Set(["photos.app.goo.gl", "photos.google.com"]);
|
||||
|
||||
async function fetchAlbumPage(shareUrl: string) {
|
||||
let current = shareUrl;
|
||||
for (let hop = 0; hop <= 5; hop += 1) {
|
||||
const url = new URL(current);
|
||||
if (url.protocol !== "https:" || !ALLOWED_PAGE_HOSTS.has(url.hostname)) throw new Error(`Album link redirected to an unexpected host (${url.hostname})`);
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.9", Accept: "text/html" },
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error("Album link redirect had no target");
|
||||
current = new URL(location, url).toString();
|
||||
continue;
|
||||
}
|
||||
if (response.status === 404) throw new Error("Album not found. Was the share link removed?");
|
||||
if (!response.ok) throw new Error(`Google Photos returned HTTP ${response.status}`);
|
||||
return new TextDecoder().decode(await readLimited(response, 20 * 1024 * 1024));
|
||||
}
|
||||
throw new Error("Album link redirected too many times");
|
||||
}
|
||||
|
||||
export function parseAlbumPage(html: string): ScrapedAlbum {
|
||||
const match = /AF_initDataCallback\(\{key: 'ds:1'[\s\S]*?data:([\s\S]*?), sideChannel: \{\}\}\);<\/script>/.exec(html);
|
||||
if (!match) throw new Error("Could not find the photo list on the album page (Google may have changed the page format)");
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(match[1]);
|
||||
} catch {
|
||||
throw new Error("Could not read the photo list on the album page (Google may have changed the page format)");
|
||||
}
|
||||
if (!Array.isArray(data) || !Array.isArray(data[1])) throw new Error("Unexpected album data format");
|
||||
const meta = Array.isArray(data[3]) ? data[3] : [];
|
||||
const items: ScrapedItem[] = [];
|
||||
for (const raw of data[1] as unknown[]) {
|
||||
if (!Array.isArray(raw) || typeof raw[0] !== "string" || !Array.isArray(raw[1])) continue;
|
||||
const media = raw[1] as unknown[];
|
||||
const baseUrl = String(media[0] ?? "");
|
||||
let host = "";
|
||||
try {
|
||||
host = new URL(baseUrl).hostname;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (host !== "lh3.googleusercontent.com") continue;
|
||||
const extras = raw[raw.length - 1];
|
||||
items.push({
|
||||
googleId: raw[0],
|
||||
baseUrl,
|
||||
width: Number(media[1]) || 0,
|
||||
height: Number(media[2]) || 0,
|
||||
takenAt: typeof raw[2] === "number" ? raw[2] : null,
|
||||
video: Boolean(extras && typeof extras === "object" && !Array.isArray(extras) && VIDEO_MARKER in extras),
|
||||
});
|
||||
}
|
||||
return {
|
||||
title: typeof meta[1] === "string" ? meta[1] : "",
|
||||
items,
|
||||
hasMorePages: typeof data[2] === "string" && data[2].length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrapeAlbum(shareUrl: string) {
|
||||
return parseAlbumPage(await fetchAlbumPage(shareUrl));
|
||||
}
|
||||
|
||||
async function downloadImage(baseUrl: string, edge: number, maxBytes: number) {
|
||||
const url = new URL(`${baseUrl}=w${edge}-h${edge}`);
|
||||
if (url.hostname !== "lh3.googleusercontent.com") throw new Error("Unexpected image host");
|
||||
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, redirect: "error", signal: AbortSignal.timeout(30_000) });
|
||||
if (!response.ok) throw new Error(`Image download failed (HTTP ${response.status})`);
|
||||
const data = await readLimited(response, maxBytes);
|
||||
if (!isJpeg(data)) throw new Error("Image was not a JPEG");
|
||||
return data;
|
||||
}
|
||||
|
||||
const photoKey = (googleId: string) => createHash("sha1").update(googleId).digest("hex").slice(0, 20);
|
||||
|
||||
// ---------- syncing ----------
|
||||
|
||||
const syncing = new Set<string>();
|
||||
export const isSyncing = (albumId: string) => syncing.has(albumId);
|
||||
|
||||
async function mapLimit<T>(values: T[], limit: number, task: (value: T) => Promise<void>) {
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
||||
while (next < values.length) {
|
||||
const value = values[next];
|
||||
next += 1;
|
||||
await task(value);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export async function syncAlbum(album: PhotoAlbum) {
|
||||
if (syncing.has(album.id)) return readIndex(album.id);
|
||||
syncing.add(album.id);
|
||||
const index = await readIndex(album.id);
|
||||
const now = Date.now();
|
||||
index.lastAttemptAt = now;
|
||||
if (!index.firstAttemptAt) index.firstAttemptAt = now;
|
||||
try {
|
||||
const scraped = await scrapeAlbum(album.shareUrl);
|
||||
const wanted = scraped.items.filter((item) => !item.video).slice(0, MAX_PHOTOS_PER_ALBUM);
|
||||
if (!wanted.length && index.photos.length) throw new Error("Album page listed no photos; keeping the cached ones");
|
||||
await mkdir(dirFor(album.id), { recursive: true, mode: 0o700 });
|
||||
const existing = new Set((await readdir(dirFor(album.id))).filter(validPhotoFile));
|
||||
const photos: CachedPhoto[] = [];
|
||||
const failures: string[] = [];
|
||||
await mapLimit(wanted, 4, async (item) => {
|
||||
const key = photoKey(item.googleId);
|
||||
try {
|
||||
if (!existing.has(`${key}.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.jpg`), await downloadImage(item.baseUrl, PHOTO_EDGE, 15 * 1024 * 1024));
|
||||
if (!existing.has(`${key}.t.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.t.jpg`), await downloadImage(item.baseUrl, THUMB_EDGE, 512 * 1024));
|
||||
photos.push({ key, takenAt: item.takenAt, width: item.width, height: item.height });
|
||||
} catch (error) {
|
||||
failures.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
const keep = new Set(photos.flatMap((photo) => [`${photo.key}.jpg`, `${photo.key}.t.jpg`]));
|
||||
for (const file of existing) if (!keep.has(file)) await unlink(photoFilePath(album.id, file)).catch(() => undefined);
|
||||
|
||||
const order = new Map(wanted.map((item, position) => [photoKey(item.googleId), position]));
|
||||
photos.sort((left, right) => (order.get(left.key) ?? 0) - (order.get(right.key) ?? 0));
|
||||
index.title = scraped.title;
|
||||
index.photos = photos;
|
||||
index.truncated = scraped.hasMorePages || scraped.items.length > MAX_PHOTOS_PER_ALBUM;
|
||||
if (failures.length && !photos.length) throw new Error(`Every download failed: ${failures[0]}`);
|
||||
index.lastSuccessAt = Date.now();
|
||||
index.lastError = failures.length ? `${failures.length} photo(s) failed to download: ${failures[0]}` : "";
|
||||
console.log(`[photos] synced "${scraped.title}" (${album.id.slice(0, 8)}): ${photos.length} photos${failures.length ? `, ${failures.length} failed` : ""}`);
|
||||
} catch (error) {
|
||||
index.lastError = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[photos] sync failed for ${album.id.slice(0, 8)}: ${index.lastError}`);
|
||||
} finally {
|
||||
await writeIndex(album.id, index).catch(() => undefined);
|
||||
syncing.delete(album.id);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Deletes cache folders of albums that are no longer in settings. */
|
||||
export async function removeOrphanedAlbumFolders() {
|
||||
const { photos } = await readSettings();
|
||||
await mkdir(photosRoot(), { recursive: true, mode: 0o700 });
|
||||
const known = new Set(photos.albums.map((album) => album.id));
|
||||
for (const entry of await readdir(photosRoot())) {
|
||||
if (entry !== UPLOADS_DIR && validPhotoDir(entry) && !known.has(entry) && !syncing.has(entry)) {
|
||||
await rm(dirFor(entry), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let syncAllRunning: Promise<void> | null = null;
|
||||
|
||||
/** Syncs every enabled album and deletes cache folders of albums that were removed. */
|
||||
export function syncAll() {
|
||||
if (syncAllRunning) return syncAllRunning;
|
||||
syncAllRunning = (async () => {
|
||||
await removeOrphanedAlbumFolders();
|
||||
const { photos } = await readSettings();
|
||||
for (const album of photos.albums.filter((entry) => entry.enabled)) await syncAlbum(album);
|
||||
})()
|
||||
.catch((error) => console.warn(`[photos] sync run failed: ${error instanceof Error ? error.message : String(error)}`))
|
||||
.finally(() => {
|
||||
syncAllRunning = null;
|
||||
});
|
||||
return syncAllRunning;
|
||||
}
|
||||
|
||||
let schedulerStarted = false;
|
||||
export function startPhotoScheduler() {
|
||||
if (schedulerStarted) return;
|
||||
schedulerStarted = true;
|
||||
const run = () => void syncAll();
|
||||
setTimeout(run, 15_000).unref();
|
||||
setInterval(run, SYNC_INTERVAL_MS).unref();
|
||||
}
|
||||
|
||||
// ---------- uploads ----------
|
||||
|
||||
let uploadQueue: Promise<unknown> = Promise.resolve();
|
||||
function serializedUploads<T>(task: () => Promise<T>) {
|
||||
const run = uploadQueue.then(task, task);
|
||||
uploadQueue = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
export function addUpload(photo: Uint8Array, thumb: Uint8Array, meta: { takenAt: number | null; width: number; height: number }) {
|
||||
return serializedUploads(async () => {
|
||||
const index = await readIndex(UPLOADS_DIR);
|
||||
if (index.photos.length >= MAX_UPLOADS) throw new Error(`At most ${MAX_UPLOADS} uploaded photos`);
|
||||
const key = createHash("sha1").update(photo).digest("hex").slice(0, 20);
|
||||
if (index.photos.some((entry) => entry.key === key)) return { key, duplicate: true };
|
||||
await mkdir(dirFor(UPLOADS_DIR), { recursive: true, mode: 0o700 });
|
||||
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.jpg`), photo);
|
||||
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.t.jpg`), thumb);
|
||||
index.photos.push({ key, ...meta });
|
||||
index.title = "Uploaded photos";
|
||||
index.lastSuccessAt = Date.now();
|
||||
await writeIndex(UPLOADS_DIR, index);
|
||||
return { key, duplicate: false };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeUpload(key: string) {
|
||||
return serializedUploads(async () => {
|
||||
if (!/^[a-f0-9]{20}$/.test(key)) return false;
|
||||
const index = await readIndex(UPLOADS_DIR);
|
||||
const before = index.photos.length;
|
||||
index.photos = index.photos.filter((entry) => entry.key !== key);
|
||||
if (index.photos.length === before) return false;
|
||||
await writeIndex(UPLOADS_DIR, index);
|
||||
for (const file of [`${key}.jpg`, `${key}.t.jpg`]) await unlink(photoFilePath(UPLOADS_DIR, file)).catch(() => undefined);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- what the slideshow shows ----------
|
||||
|
||||
export type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number };
|
||||
|
||||
export async function currentSlideshow() {
|
||||
const settings = await readSettings();
|
||||
const { albums, ...display } = settings.photos;
|
||||
const sources = albums.filter((album) => album.enabled).map((album) => album.id);
|
||||
if (display.includeUploads) sources.push(UPLOADS_DIR);
|
||||
const photos: SlidePhoto[] = [];
|
||||
const fingerprint = createHash("sha1").update(settings.updatedAt);
|
||||
for (const source of sources) {
|
||||
const index = await readIndex(source);
|
||||
fingerprint.update(`${source}:${index.lastSuccessAt}:${index.photos.length}`);
|
||||
for (const photo of index.photos) {
|
||||
photos.push({
|
||||
src: `/api/photos/img/${source}/${photo.key}.jpg`,
|
||||
thumb: `/api/photos/img/${source}/${photo.key}.t.jpg`,
|
||||
takenAt: photo.takenAt,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { display, photos, version: fingerprint.digest("hex").slice(0, 16) };
|
||||
}
|
||||
|
||||
/** Albums whose last successful sync is older than 48h (or never succeeded within 48h of being added). */
|
||||
export async function staleAlbums() {
|
||||
const { photos } = await readSettings();
|
||||
const now = Date.now();
|
||||
const stale: { id: string; title: string; lastSuccessAt: number | null; lastError: string }[] = [];
|
||||
for (const album of photos.albums.filter((entry) => entry.enabled)) {
|
||||
const index = await readIndex(album.id);
|
||||
const reference = index.lastSuccessAt ?? (index.firstAttemptAt || now);
|
||||
if (now - reference > STALE_AFTER_MS) stale.push({ id: album.id, title: album.name || index.title, lastSuccessAt: index.lastSuccessAt, lastError: index.lastError });
|
||||
}
|
||||
return stale;
|
||||
}
|
||||
@@ -16,12 +16,86 @@ export type CalendarSetting = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PhotoAlbum = {
|
||||
id: string;
|
||||
/** Display name override; empty means "use the album's own title". */
|
||||
name: string;
|
||||
shareUrl: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PhotoDisplay = {
|
||||
intervalSeconds: number;
|
||||
shuffle: boolean;
|
||||
showDate: boolean;
|
||||
fit: "contain-blur" | "cover";
|
||||
includeUploads: boolean;
|
||||
};
|
||||
|
||||
export type PhotoSettings = PhotoDisplay & { albums: PhotoAlbum[] };
|
||||
|
||||
export const MAX_ALBUMS = 10;
|
||||
export const DEFAULT_PHOTO_SETTINGS: PhotoSettings = {
|
||||
intervalSeconds: 20,
|
||||
shuffle: true,
|
||||
showDate: true,
|
||||
fit: "contain-blur",
|
||||
includeUploads: true,
|
||||
albums: [],
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
schema: 1;
|
||||
updatedAt: string;
|
||||
calendars: CalendarSetting[];
|
||||
photos: PhotoSettings;
|
||||
};
|
||||
|
||||
/** Accepts photos.app.goo.gl short links and photos.google.com/share links. */
|
||||
export function validAlbumUrl(value: unknown) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(String(value).trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:") return null;
|
||||
if (url.hostname === "photos.app.goo.gl" && /^\/[A-Za-z0-9_-]{6,64}$/.test(url.pathname)) return `https://photos.app.goo.gl${url.pathname}`;
|
||||
if (url.hostname === "photos.google.com" && /^\/share\/[A-Za-z0-9_-]{10,200}$/.test(url.pathname)) return url.toString();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function maskAlbumUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
const id = parsed.pathname.split("/").pop() || "";
|
||||
return `${parsed.hostname}/…${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function normalizePhotos(raw: unknown): PhotoSettings {
|
||||
const value = (raw ?? {}) as Partial<PhotoSettings>;
|
||||
const interval = Number(value.intervalSeconds);
|
||||
return {
|
||||
intervalSeconds: Number.isFinite(interval) ? Math.min(3600, Math.max(5, Math.round(interval))) : DEFAULT_PHOTO_SETTINGS.intervalSeconds,
|
||||
shuffle: value.shuffle !== false,
|
||||
showDate: value.showDate !== false,
|
||||
fit: value.fit === "cover" ? "cover" : "contain-blur",
|
||||
includeUploads: value.includeUploads !== false,
|
||||
albums: (Array.isArray(value.albums) ? value.albums : [])
|
||||
.map((entry) => {
|
||||
const shareUrl = validAlbumUrl(entry?.shareUrl);
|
||||
if (!shareUrl) return null;
|
||||
return {
|
||||
id: typeof entry.id === "string" && /^[A-Za-z0-9-]{8,64}$/.test(entry.id) ? entry.id : randomUUID(),
|
||||
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "",
|
||||
shareUrl,
|
||||
enabled: entry.enabled !== false,
|
||||
} satisfies PhotoAlbum;
|
||||
})
|
||||
.filter((entry): entry is PhotoAlbum => entry !== null)
|
||||
.slice(0, MAX_ALBUMS),
|
||||
};
|
||||
}
|
||||
|
||||
export type AuthState = {
|
||||
passwordHash: string;
|
||||
sessionSecret: string;
|
||||
@@ -74,6 +148,7 @@ function normalizeSettings(raw: unknown): Settings {
|
||||
})
|
||||
.filter((entry): entry is CalendarSetting => entry !== null)
|
||||
.slice(0, MAX_CALENDARS),
|
||||
photos: normalizePhotos(value.photos),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,7 +178,7 @@ export async function readSettings(): Promise<Settings> {
|
||||
}
|
||||
}
|
||||
|
||||
export type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string };
|
||||
export type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string };
|
||||
|
||||
async function pruneHistory() {
|
||||
const files = (await readdir(historyDir())).filter((name) => name.endsWith(".json")).sort();
|
||||
@@ -112,15 +187,17 @@ async function pruneHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves new settings, first snapshotting the current ones into history. */
|
||||
export function saveSettings(calendars: CalendarSetting[], note: string) {
|
||||
/** Saves new settings (only the parts given), first snapshotting the current ones into history. */
|
||||
export function saveSettings(patch: Partial<Pick<Settings, "calendars" | "photos">>, note: string) {
|
||||
return serialized(async () => {
|
||||
const current = await readSettings();
|
||||
const calendars = patch.calendars ?? current.calendars;
|
||||
const photos = patch.photos ?? current.photos;
|
||||
const stamp = new Date().toISOString();
|
||||
const snapshotId = `${stamp.replace(/[:.]/g, "-")}`;
|
||||
await writeJsonAtomic(path.join(historyDir(), `${snapshotId}.json`), { ...current, note });
|
||||
await pruneHistory();
|
||||
const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars });
|
||||
const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos });
|
||||
await writeJsonAtomic(settingsPath(), next);
|
||||
return next;
|
||||
});
|
||||
@@ -136,6 +213,7 @@ export async function listHistory(): Promise<HistoryEntry[]> {
|
||||
id: name.slice(0, -5),
|
||||
savedAt: raw.updatedAt,
|
||||
calendarCount: Array.isArray(raw.calendars) ? raw.calendars.length : 0,
|
||||
albumCount: Array.isArray(raw.photos?.albums) ? raw.photos.albums.length : 0,
|
||||
note: typeof raw.note === "string" ? raw.note : "",
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -26,7 +26,7 @@ type DraftCalendar = {
|
||||
replacing: boolean;
|
||||
test?: TestResult | "running";
|
||||
};
|
||||
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string };
|
||||
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 }) {
|
||||
@@ -331,7 +331,7 @@ function CalendarsTab() {
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">History</h2>
|
||||
<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>
|
||||
) : (
|
||||
@@ -339,7 +339,7 @@ function CalendarsTab() {
|
||||
{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)</span>
|
||||
{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
|
||||
@@ -353,6 +353,344 @@ function CalendarsTab() {
|
||||
);
|
||||
}
|
||||
|
||||
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("");
|
||||
@@ -381,8 +719,8 @@ function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<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 (8+ characters)" value={next} onChange={(event) => setNext(event.target.value)} />
|
||||
<button className={primaryButton} disabled={!current || next.length < 8}>
|
||||
<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>}
|
||||
@@ -449,11 +787,7 @@ export default function AdminPage() {
|
||||
))}
|
||||
</nav>
|
||||
{tab === "calendars" && <CalendarsTab />}
|
||||
{tab === "photos" && (
|
||||
<p className="rounded-xl border border-white/10 bg-[#0c121d] p-4 text-sm text-slate-400">
|
||||
Photo albums arrive in the next update.
|
||||
</p>
|
||||
)}
|
||||
{tab === "photos" && <PhotosTab />}
|
||||
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
if (new Set(next.map((calendar) => calendar.url)).size !== next.length) return json({ error: "The same calendar is listed twice." }, 400);
|
||||
|
||||
const saved = await saveSettings(next, `Saved ${next.length} calendar(s)`);
|
||||
const saved = await saveSettings({ calendars: next }, `Saved ${next.length} calendar(s)`);
|
||||
console.log(`[admin] calendars saved (${saved.calendars.length})`);
|
||||
return json({ calendars: publicView(saved.calendars), updatedAt: saved.updatedAt });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
|
||||
import { syncAll } from "../../../_lib/photos";
|
||||
import { listHistory, readHistorySnapshot, saveSettings } from "../../../_lib/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -9,14 +10,15 @@ export async function GET(request: Request) {
|
||||
return json({ history: await listHistory() });
|
||||
}
|
||||
|
||||
/** Reverts calendars to a snapshot. The current state is itself snapshotted first, so a revert can be undone. */
|
||||
/** Reverts calendars and photo settings to a snapshot. The current state is itself snapshotted first, so a revert can be undone. */
|
||||
export async function POST(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
const body = await readJsonBody<{ id?: string }>(request);
|
||||
const snapshot = body?.id ? await readHistorySnapshot(body.id) : null;
|
||||
if (!snapshot) return json({ error: "That version no longer exists." }, 404);
|
||||
const saved = await saveSettings(snapshot.calendars, `Reverted to version from ${snapshot.updatedAt}`);
|
||||
console.log(`[admin] calendars reverted to ${body?.id}`);
|
||||
const saved = await saveSettings({ calendars: snapshot.calendars, photos: snapshot.photos }, `Reverted to version from ${snapshot.updatedAt}`);
|
||||
console.log(`[admin] settings reverted to ${body?.id}`);
|
||||
void syncAll(); // re-fetch any album the restored version brings back
|
||||
return json({ ok: true, updatedAt: saved.updatedAt });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
|
||||
import { MAX_PHOTOS_PER_ALBUM, scrapeAlbum } from "../../../../_lib/photos";
|
||||
import { validAlbumUrl } from "../../../../_lib/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Reads a share link without saving it: album title and how many photos/videos it has. */
|
||||
export async function POST(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
const body = await readJsonBody<{ shareUrl?: string }>(request);
|
||||
const shareUrl = validAlbumUrl(body?.shareUrl);
|
||||
if (!shareUrl) return json({ ok: false, error: "Not a Google Photos share link." }, 400);
|
||||
try {
|
||||
const album = await scrapeAlbum(shareUrl);
|
||||
const videos = album.items.filter((item) => item.video).length;
|
||||
return json({
|
||||
ok: true,
|
||||
title: album.title,
|
||||
photos: album.items.length - videos,
|
||||
videos,
|
||||
truncated: album.hasMorePages || album.items.length - videos > MAX_PHOTOS_PER_ALBUM,
|
||||
});
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: error instanceof Error ? error.message : "Could not read the album." });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
|
||||
import { isSyncing, readIndex, removeOrphanedAlbumFolders, STALE_AFTER_MS, syncAlbum, UPLOADS_DIR } from "../../../_lib/photos";
|
||||
import { MAX_ALBUMS, maskAlbumUrl, type PhotoAlbum, type PhotoSettings, readSettings, saveSettings, validAlbumUrl } from "../../../_lib/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function view(photos: PhotoSettings) {
|
||||
const { albums, ...display } = photos;
|
||||
const uploads = await readIndex(UPLOADS_DIR);
|
||||
return {
|
||||
display,
|
||||
albums: await Promise.all(
|
||||
albums.map(async (album) => {
|
||||
const index = await readIndex(album.id);
|
||||
return {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
enabled: album.enabled,
|
||||
masked: maskAlbumUrl(album.shareUrl),
|
||||
title: index.title,
|
||||
photoCount: index.photos.length,
|
||||
lastSuccessAt: index.lastSuccessAt,
|
||||
lastAttemptAt: index.lastAttemptAt,
|
||||
lastError: index.lastError,
|
||||
truncated: index.truncated,
|
||||
stale: album.enabled && Date.now() - (index.lastSuccessAt ?? (index.firstAttemptAt || Date.now())) > STALE_AFTER_MS,
|
||||
syncing: isSyncing(album.id),
|
||||
};
|
||||
}),
|
||||
),
|
||||
uploads: uploads.photos.map((photo) => ({ key: photo.key, thumb: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.t.jpg`, src: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.jpg` })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const denied = await requireAdmin(request);
|
||||
if (denied) return denied;
|
||||
return json(await view((await readSettings()).photos));
|
||||
}
|
||||
|
||||
type IncomingAlbum = { id?: string; name?: string; enabled?: boolean; shareUrl?: string };
|
||||
type Incoming = { display?: Partial<Omit<PhotoSettings, "albums">>; albums?: IncomingAlbum[] };
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
const body = await readJsonBody<Incoming>(request);
|
||||
if (!body || !Array.isArray(body.albums)) return json({ error: "Expected albums and display settings." }, 400);
|
||||
if (body.albums.length > MAX_ALBUMS) return json({ error: `At most ${MAX_ALBUMS} albums.` }, 400);
|
||||
|
||||
const current = (await readSettings()).photos;
|
||||
const existing = new Map(current.albums.map((album) => [album.id, album]));
|
||||
const albums: PhotoAlbum[] = [];
|
||||
const toSync: PhotoAlbum[] = [];
|
||||
for (const [position, entry] of body.albums.entries()) {
|
||||
const label = entry.name?.trim() || `Album ${position + 1}`;
|
||||
const previous = entry.id ? existing.get(entry.id) : undefined;
|
||||
if (entry.id && !previous) return json({ error: `${label}: unknown album; reload the page.` }, 409);
|
||||
const supplied = typeof entry.shareUrl === "string" && entry.shareUrl.trim() ? validAlbumUrl(entry.shareUrl) : null;
|
||||
if (entry.shareUrl?.trim() && !supplied) return json({ error: `${label}: paste a Google Photos share link (https://photos.app.goo.gl/… or https://photos.google.com/share/…).` }, 400);
|
||||
const shareUrl = supplied ?? previous?.shareUrl;
|
||||
if (!shareUrl) return json({ error: `${label}: a share link is required.` }, 400);
|
||||
const album: PhotoAlbum = {
|
||||
id: previous?.id ?? randomUUID(),
|
||||
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "",
|
||||
shareUrl,
|
||||
enabled: entry.enabled !== false,
|
||||
};
|
||||
albums.push(album);
|
||||
if (album.enabled && (!previous || previous.shareUrl !== shareUrl || !previous.enabled)) toSync.push(album);
|
||||
}
|
||||
if (new Set(albums.map((album) => album.shareUrl)).size !== albums.length) return json({ error: "The same album is listed twice." }, 400);
|
||||
|
||||
const saved = await saveSettings({ photos: { ...current, ...(body.display ?? {}), albums } }, `Saved photo settings (${albums.length} album(s))`);
|
||||
console.log(`[admin] photo settings saved (${albums.length} album(s))`);
|
||||
// Fetch new/changed albums now, and drop cache folders of removed ones, without making the browser wait.
|
||||
void (async () => {
|
||||
await removeOrphanedAlbumFolders();
|
||||
for (const album of toSync) await syncAlbum(album);
|
||||
})();
|
||||
return json(await view(saved.photos));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
|
||||
import { syncAlbum, syncAll } from "../../../../_lib/photos";
|
||||
import { readSettings } from "../../../../_lib/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Syncs one album ({ id }) or all of them, and waits for the result. */
|
||||
export async function POST(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
const body = await readJsonBody<{ id?: string }>(request);
|
||||
if (body?.id) {
|
||||
const album = (await readSettings()).photos.albums.find((entry) => entry.id === body.id);
|
||||
if (!album) return json({ error: "Unknown album." }, 404);
|
||||
const index = await syncAlbum(album);
|
||||
return json({ ok: !index.lastError || index.photos.length > 0, photoCount: index.photos.length, lastError: index.lastError });
|
||||
}
|
||||
await syncAll();
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { json, requireAdmin } from "../../../../_lib/auth";
|
||||
import { addUpload, isJpeg, removeUpload } from "../../../../_lib/photos";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* The admin page resizes photos in the browser (max 1920px JPEG plus a 64px thumbnail)
|
||||
* before sending them, so the server only validates and stores.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return json({ error: "Expected a photo upload." }, 400);
|
||||
}
|
||||
const photo = form.get("photo");
|
||||
const thumb = form.get("thumb");
|
||||
if (!(photo instanceof Blob) || !(thumb instanceof Blob)) return json({ error: "Missing photo." }, 400);
|
||||
if (photo.size > 8 * 1024 * 1024 || thumb.size > 256 * 1024) return json({ error: "Photo is too large." }, 413);
|
||||
const photoBytes = new Uint8Array(await photo.arrayBuffer());
|
||||
const thumbBytes = new Uint8Array(await thumb.arrayBuffer());
|
||||
if (!isJpeg(photoBytes) || !isJpeg(thumbBytes)) return json({ error: "Photos must be JPEG." }, 415);
|
||||
const takenAt = Number(form.get("takenAt"));
|
||||
try {
|
||||
const result = await addUpload(photoBytes, thumbBytes, {
|
||||
takenAt: Number.isFinite(takenAt) && takenAt > 0 ? takenAt : null,
|
||||
width: Math.max(0, Math.round(Number(form.get("width")) || 0)),
|
||||
height: Math.max(0, Math.round(Number(form.get("height")) || 0)),
|
||||
});
|
||||
return json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : "Upload failed." }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
const key = new URL(request.url).searchParams.get("key") || "";
|
||||
return (await removeUpload(key)) ? json({ ok: true }) : json({ error: "Not found." }, 404);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { currentSlideshow, staleAlbums } from "../../../_lib/photos";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** 503 when any enabled album has not synced successfully for 48 hours. Probed by Prometheus. */
|
||||
export async function GET() {
|
||||
const stale = await staleAlbums();
|
||||
const { photos } = await currentSlideshow();
|
||||
return Response.json(
|
||||
{ status: stale.length ? "stale" : "ok", photos: photos.length, staleAlbums: stale.map(({ title, lastSuccessAt, lastError }) => ({ title, lastSuccessAt, lastError })) },
|
||||
{ status: stale.length ? 503 : 200, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function GET() {
|
||||
{
|
||||
status: "ok",
|
||||
service: "hobbs-family-organizer",
|
||||
version: "1.1.0",
|
||||
version: "1.2.0",
|
||||
calendarConfigurationValid: calendars.some((calendar) => calendar.enabled),
|
||||
calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length,
|
||||
adminConfigured,
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { photoFilePath, validPhotoDir, validPhotoFile } from "../../../../../_lib/photos";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ dir: string; file: string }> }) {
|
||||
const { dir, file } = await params;
|
||||
if (!validPhotoDir(dir) || !validPhotoFile(file)) return new Response("Not found", { status: 404 });
|
||||
try {
|
||||
const data = await readFile(photoFilePath(dir, file));
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", "Content-Length": String(data.byteLength) },
|
||||
});
|
||||
} catch {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { currentSlideshow } from "../../_lib/photos";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(await currentSlideshow(), { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hobbs Family Photos",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PhotoLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/* eslint-disable @next/next/no-img-element -- plain <img> is intended: same-origin cached JPEGs, no optimizer */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number };
|
||||
type Display = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean };
|
||||
type Slideshow = { display: Display; photos: SlidePhoto[]; version: string };
|
||||
type Layer = { photo: SlidePhoto; id: number };
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
const FADE_MS = 1_400;
|
||||
const FULL_RELOAD_MS = 12 * 60 * 60 * 1000;
|
||||
const TIME_ZONE = "America/Chicago";
|
||||
|
||||
function shuffled<T>(values: T[]) {
|
||||
const copy = [...values];
|
||||
for (let index = copy.length - 1; index > 0; index -= 1) {
|
||||
const other = Math.floor(Math.random() * (index + 1));
|
||||
[copy[index], copy[other]] = [copy[other], copy[index]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function formatTaken(takenAt: number | null) {
|
||||
if (!takenAt) return "";
|
||||
return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt));
|
||||
}
|
||||
|
||||
function preload(src: string) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => resolve();
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export default function PhotoFrame() {
|
||||
const [show, setShow] = useState<Slideshow | null>(null);
|
||||
const [layers, setLayers] = useState<Layer[]>([]);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const queue = useRef<SlidePhoto[]>([]);
|
||||
const versionRef = useRef("");
|
||||
const showRef = useRef<Slideshow | null>(null);
|
||||
const layerId = useRef(0);
|
||||
// The newest layer's src, readable from callbacks without depending on layers state.
|
||||
const topSrc = useRef("");
|
||||
|
||||
const refill = useCallback((photos: SlidePhoto[], shuffle: boolean, avoid?: string) => {
|
||||
let next = shuffle ? shuffled(photos) : [...photos];
|
||||
if (next.length > 1 && next[0].src === avoid) next = [...next.slice(1), next[0]];
|
||||
queue.current = next;
|
||||
}, []);
|
||||
|
||||
const advance = useCallback(async () => {
|
||||
const current = showRef.current;
|
||||
if (!current || !current.photos.length) return;
|
||||
if (!queue.current.length) refill(current.photos, current.display.shuffle, topSrc.current);
|
||||
const photo = queue.current.shift();
|
||||
if (!photo) return;
|
||||
await preload(photo.src);
|
||||
layerId.current += 1;
|
||||
const id = layerId.current;
|
||||
setLayers((existing) => [...existing.slice(-1), { photo, id }]);
|
||||
}, [refill]);
|
||||
|
||||
useEffect(() => {
|
||||
topSrc.current = layers[layers.length - 1]?.photo.src ?? "";
|
||||
}, [layers]);
|
||||
|
||||
// Poll settings + photo list; rebuild the queue only when something changed.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/photos", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
const next = (await response.json()) as Slideshow;
|
||||
if (cancelled) return;
|
||||
setFailed(false);
|
||||
if (next.version === versionRef.current) return;
|
||||
const first = !versionRef.current;
|
||||
versionRef.current = next.version;
|
||||
showRef.current = next;
|
||||
setShow(next);
|
||||
refill(next.photos, next.display.shuffle, topSrc.current);
|
||||
if (first || !topSrc.current || !next.photos.some((photo) => photo.src === topSrc.current)) void advance();
|
||||
} catch {
|
||||
if (!cancelled) setFailed(true);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(poll, POLL_MS);
|
||||
const reload = window.setTimeout(() => window.location.reload(), FULL_RELOAD_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
window.clearTimeout(reload);
|
||||
};
|
||||
}, [advance, refill]);
|
||||
|
||||
const intervalSeconds = show?.display.intervalSeconds ?? 20;
|
||||
const photoCount = show?.photos.length ?? 0;
|
||||
useEffect(() => {
|
||||
if (photoCount < 2) return;
|
||||
const timer = window.setInterval(() => void advance(), intervalSeconds * 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [advance, intervalSeconds, photoCount]);
|
||||
|
||||
const fit = show?.display.fit ?? "contain-blur";
|
||||
const showDate = show?.display.showDate ?? true;
|
||||
const top = layers[layers.length - 1];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden bg-black" style={{ cursor: "none" }}>
|
||||
{layers.map((layer) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute inset-0"
|
||||
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both` }}
|
||||
>
|
||||
{fit === "contain-blur" && (
|
||||
<img
|
||||
src={layer.photo.thumb}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={layer.photo.src}
|
||||
alt=""
|
||||
className={`absolute inset-0 h-full w-full ${fit === "cover" ? "object-cover" : "object-contain"}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showDate && top?.photo.takenAt ? (
|
||||
<div
|
||||
key={`date-${top.id}`}
|
||||
className="absolute bottom-6 right-8 rounded-full bg-black/35 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85"
|
||||
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both`, textShadow: "0 1px 4px rgb(0 0 0 / 60%)" }}
|
||||
>
|
||||
{formatTaken(top.photo.takenAt)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{show && !show.photos.length && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-center text-2xl text-white/60">
|
||||
No photos yet. Add a Google Photos album in the admin screen.
|
||||
</div>
|
||||
)}
|
||||
{failed && !layers.length && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-2xl text-white/60">Can't reach the photo server…</div>
|
||||
)}
|
||||
|
||||
<style>{`@keyframes photo-fade-in { from { opacity: 0 } to { opacity: 1 } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ name: hobbs-family-organizer
|
||||
|
||||
services:
|
||||
organizer:
|
||||
image: hobbs-family-organizer:1.1.0
|
||||
image: hobbs-family-organizer:1.2.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
const { startPhotoScheduler } = await import("./app/_lib/photos");
|
||||
startPhotoScheduler();
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hobbs-family-organizer",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hobbs-family-organizer",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"ical.js": "^2.2.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hobbs-family-organizer",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
|
||||
Reference in New Issue
Block a user