Add the profile page, and publish it as an embeddable panel

Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.

A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.

  - watch_history (0024) records what has been played, keyed on the Plex
    history id so a re-sync cannot duplicate a row. Partial unique index,
    because that id is null for anything entered by hand.
  - plex_accounts.is_server_owner (0025) marks the one account whose viewing
    the server files under local account 1 rather than under its plex.tv id.
  - The embed carries its own layout, origin allowlist and a frame-height
    reporter, so the host page can size it without guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
odin
2026-08-17 15:42:17 +02:00
parent 3ccae54259
commit 1dfa2c0a04
30 changed files with 8119 additions and 233 deletions
+16 -5
View File
@@ -14,6 +14,17 @@ type WatchKind = "television" | "movies";
type WatchNowResult = { ok: true } | { ok: false; message: string };
/**
* The board, the profile page and the embed all show Watch Now, so all three
* are stale the moment any of them changes. Revalidating only "/" was correct
* when the board was the only place slots were visible; it no longer is.
*/
function revalidateWatchNow() {
revalidatePath("/");
revalidatePath("/profile");
revalidatePath("/embed/profile");
}
function validDate(value: FormDataEntryValue | null) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
@@ -169,7 +180,7 @@ export async function addToWatchNow(formData: FormData): Promise<WatchNowResult
: await firstOpenSlot(session.user.id, mediaType, quota);
if (!targetSlot) {
revalidatePath("/");
revalidateWatchNow();
return { ok: false, message: "No Watch Now slots are open." };
}
const overview = optionalString(formData.get("overview"));
@@ -217,7 +228,7 @@ export async function addToWatchNow(formData: FormData): Promise<WatchNowResult
});
}
revalidatePath("/");
revalidateWatchNow();
return { ok: true };
}
@@ -253,7 +264,7 @@ export async function removeFromWatchNowByTmdbId(formData: FormData): Promise<Wa
);
}
revalidatePath("/");
revalidateWatchNow();
return { ok: true };
}
@@ -271,7 +282,7 @@ export async function removeFromWatchNow(formData: FormData) {
.set({ removedAt: new Date() })
.where(and(eq(watchingNowItems.id, itemId), eq(watchingNowItems.userId, session.user.id)));
revalidatePath("/");
revalidateWatchNow();
}
export async function moveWatchNowItem(formData: FormData) {
@@ -312,5 +323,5 @@ export async function moveWatchNowItem(formData: FormData) {
await clearSlot(session.user.id, mediaType, targetSlot, itemId);
await db.update(watchingNowItems).set({ slotNumber: targetSlot }).where(eq(watchingNowItems.id, itemId));
revalidatePath("/");
revalidateWatchNow();
}