style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass (`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
This commit is contained in:
+152
-74
@@ -237,7 +237,8 @@ function localizeDetail(d: any): string {
|
||||
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
|
||||
return t("downloads.errors.quota.generic");
|
||||
}
|
||||
if (d?.code === "edit") return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (d?.code === "edit")
|
||||
return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
@@ -789,7 +790,13 @@ export interface PlexItemDetail {
|
||||
markers: PlexMarker[];
|
||||
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
||||
// `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
|
||||
subtitle_streams: { ord: number; label: string; language?: string | null; codec?: string; text: boolean }[];
|
||||
subtitle_streams: {
|
||||
ord: number;
|
||||
label: string;
|
||||
language?: string | null;
|
||||
codec?: string;
|
||||
text: boolean;
|
||||
}[];
|
||||
status: string;
|
||||
position_seconds: number;
|
||||
show_title?: string | null;
|
||||
@@ -992,13 +999,7 @@ interface DownloadAsset {
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export type DownloadStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "done"
|
||||
| "error"
|
||||
| "canceled";
|
||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
||||
|
||||
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
|
||||
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
||||
@@ -1101,8 +1102,7 @@ export const api = {
|
||||
return r && r.authenticated ? (r as Me) : null;
|
||||
},
|
||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||
deleteAccount: (): Promise<{ deleted: boolean }> =>
|
||||
req("/api/me/account", { method: "DELETE" }),
|
||||
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
|
||||
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
||||
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
||||
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
|
||||
@@ -1138,7 +1138,7 @@ export const api = {
|
||||
req(
|
||||
`/api/videos/${id}/state`,
|
||||
{ method: "POST", body: JSON.stringify({ status }) },
|
||||
{ idempotent: true }
|
||||
{ idempotent: true },
|
||||
),
|
||||
clearState: (id: string) =>
|
||||
req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
|
||||
@@ -1152,7 +1152,7 @@ export const api = {
|
||||
duration_seconds: Math.floor(durationSeconds),
|
||||
}),
|
||||
},
|
||||
{ idempotent: true }
|
||||
{ idempotent: true },
|
||||
),
|
||||
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate).
|
||||
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void =>
|
||||
@@ -1172,22 +1172,18 @@ export const api = {
|
||||
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
|
||||
updateChannel: (
|
||||
id: string,
|
||||
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
|
||||
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean },
|
||||
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
resetChannelBackfill: (id: string) =>
|
||||
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
||||
deepAll: (on = true) =>
|
||||
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
||||
deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
||||
attachChannelTag: (id: string, tagId: number) =>
|
||||
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
||||
detachChannelTag: (id: string, tagId: number) =>
|
||||
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
|
||||
unsubscribeChannel: (id: string) =>
|
||||
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||
discoveredChannels: (): Promise<DiscoveredChannel[]> =>
|
||||
req("/api/channels/discovery"),
|
||||
subscribeChannel: (id: string) =>
|
||||
req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
||||
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
|
||||
subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
||||
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
||||
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
|
||||
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
|
||||
@@ -1201,12 +1197,13 @@ export const api = {
|
||||
return req(
|
||||
`/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
|
||||
{ method: "POST" },
|
||||
{ quiet: true }
|
||||
{ quiet: true },
|
||||
);
|
||||
},
|
||||
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
|
||||
blockedChannels: (): Promise<{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]> =>
|
||||
req("/api/channels/blocked/list"),
|
||||
blockedChannels: (): Promise<
|
||||
{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]
|
||||
> => req("/api/channels/blocked/list"),
|
||||
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
|
||||
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
|
||||
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
|
||||
@@ -1247,8 +1244,7 @@ export const api = {
|
||||
req("/api/playlists/sync-youtube", { method: "POST" }),
|
||||
revertPlaylist: (id: number): Promise<{ items: number }> =>
|
||||
req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }),
|
||||
playlistPushPlan: (id: number): Promise<PushPlan> =>
|
||||
req(`/api/playlists/${id}/push-plan`),
|
||||
playlistPushPlan: (id: number): Promise<PushPlan> => req(`/api/playlists/${id}/push-plan`),
|
||||
pushPlaylist: (id: number): Promise<PushResult> =>
|
||||
req(`/api/playlists/${id}/push`, { method: "POST" }),
|
||||
|
||||
@@ -1307,18 +1303,35 @@ export const api = {
|
||||
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
// --- Collection editing (admin only; writes back to Plex) ---
|
||||
plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
|
||||
plexCreateCollection: (
|
||||
library: string,
|
||||
title: string,
|
||||
item_rating_key: string,
|
||||
): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ library, title, item_rating_key }),
|
||||
}),
|
||||
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST" }),
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
plexCollectionRemoveItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "PATCH", body: JSON.stringify({ title }) }),
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title }),
|
||||
}),
|
||||
plexDeleteCollection: (rk: string): Promise<{ deleted: string }> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
|
||||
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ editable }),
|
||||
}),
|
||||
// --- Playlists (Siftlode-native, per-user, ordered) ---
|
||||
// `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole
|
||||
// season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog).
|
||||
@@ -1334,39 +1347,76 @@ export const api = {
|
||||
},
|
||||
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
|
||||
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
|
||||
req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }),
|
||||
plexPlaylistAddBulk: (id: number, itemRks: string[]): Promise<{ added: number; item_count: number }> =>
|
||||
req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
||||
plexPlaylistRemoveBulk: (id: number, itemRks: string[]): Promise<{ removed: number; item_count: number }> =>
|
||||
req(`/api/plex/playlists/${id}/items/remove-bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
||||
req(`/api/plex/playlists`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, item_rating_key }),
|
||||
}),
|
||||
plexPlaylistAddBulk: (
|
||||
id: number,
|
||||
itemRks: string[],
|
||||
): Promise<{ added: number; item_count: number }> =>
|
||||
req(`/api/plex/playlists/${id}/items/bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ item_rating_keys: itemRks }),
|
||||
}),
|
||||
plexPlaylistRemoveBulk: (
|
||||
id: number,
|
||||
itemRks: string[],
|
||||
): Promise<{ removed: number; item_count: number }> =>
|
||||
req(`/api/plex/playlists/${id}/items/remove-bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ item_rating_keys: itemRks }),
|
||||
}),
|
||||
plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
|
||||
req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
|
||||
plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
|
||||
req(`/api/plex/playlists/${id}`, { method: "DELETE" }),
|
||||
plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
|
||||
req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }) }),
|
||||
req(`/api/plex/playlists/${id}/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ item_rating_key: itemRk }),
|
||||
}),
|
||||
plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
|
||||
req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
|
||||
plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> =>
|
||||
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
||||
req(`/api/plex/playlists/${id}/order`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ item_rating_keys: itemRks }),
|
||||
}),
|
||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
||||
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
|
||||
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ watched }),
|
||||
}),
|
||||
plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
|
||||
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ watched }),
|
||||
}),
|
||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
|
||||
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
|
||||
req(`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`),
|
||||
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
|
||||
req(
|
||||
`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`,
|
||||
),
|
||||
plexSession: (
|
||||
id: string,
|
||||
start = 0,
|
||||
audio?: number | null,
|
||||
aoff = 0,
|
||||
multi = false,
|
||||
): Promise<PlexPlaySession> => {
|
||||
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||
if (audio != null) p.set("audio", String(audio));
|
||||
if (aoff) p.set("aoff", String(aoff));
|
||||
if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch)
|
||||
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
||||
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
|
||||
// `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
|
||||
@@ -1408,7 +1458,9 @@ export const api = {
|
||||
}),
|
||||
// Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import).
|
||||
plexWatchLink: (): Promise<PlexWatchLink> => req("/api/plex/watch/link"),
|
||||
plexWatchSetLink: (enabled: boolean): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
|
||||
plexWatchSetLink: (
|
||||
enabled: boolean,
|
||||
): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
|
||||
req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }),
|
||||
plexWatchImport: (): Promise<PlexWatchLink & { import: PlexWatchImport }> =>
|
||||
req("/api/plex/watch/import", { method: "POST" }),
|
||||
@@ -1428,7 +1480,10 @@ export const api = {
|
||||
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
|
||||
req(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
||||
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
|
||||
updateSchedulerJob: (
|
||||
jobId: string,
|
||||
intervalMinutes: number,
|
||||
): Promise<{ id: string; interval_minutes: number }> =>
|
||||
req(`/api/admin/scheduler/jobs/${jobId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ interval_minutes: intervalMinutes }),
|
||||
@@ -1455,22 +1510,50 @@ export const api = {
|
||||
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
|
||||
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
|
||||
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
|
||||
req("/api/setup/admin", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) }, { quiet: true }),
|
||||
req(
|
||||
"/api/setup/admin",
|
||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
|
||||
req("/api/setup/config", { method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) }, { quiet: true }),
|
||||
req(
|
||||
"/api/setup/config",
|
||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) },
|
||||
{ quiet: true },
|
||||
),
|
||||
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
|
||||
req("/api/setup/test-email", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) }, { quiet: true }),
|
||||
req(
|
||||
"/api/setup/test-email",
|
||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
setupFinish: (token: string): Promise<{ ok: boolean }> =>
|
||||
req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
|
||||
// --- auth: email + password (errors handled inline via quiet) ---
|
||||
register: (email: string, password: string): Promise<{ status: string }> =>
|
||||
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
||||
req(
|
||||
"/auth/register",
|
||||
{ method: "POST", body: JSON.stringify({ email, password }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
|
||||
req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
||||
req(
|
||||
"/auth/password-login",
|
||||
{ method: "POST", body: JSON.stringify({ email, password }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
requestPasswordReset: (email: string): Promise<{ status: string }> =>
|
||||
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
|
||||
req(
|
||||
"/auth/password-reset/request",
|
||||
{ method: "POST", body: JSON.stringify({ email }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
||||
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
|
||||
req(
|
||||
"/auth/password-reset/confirm",
|
||||
{ method: "POST", body: JSON.stringify({ token, password }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||
@@ -1481,7 +1564,7 @@ export const api = {
|
||||
req(
|
||||
"/auth/set-password",
|
||||
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
|
||||
{ quiet: true }
|
||||
{ quiet: true },
|
||||
),
|
||||
// --- onboarding / admin ---
|
||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||
@@ -1490,18 +1573,15 @@ export const api = {
|
||||
// sign-in. Returns whether a session was established.
|
||||
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
|
||||
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> =>
|
||||
req("/api/admin/demo/whitelist"),
|
||||
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
|
||||
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
|
||||
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
removeDemoWhitelist: (id: number) =>
|
||||
req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
|
||||
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
|
||||
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
|
||||
req("/api/admin/demo/reset", { method: "POST" }),
|
||||
adminInvites: (status?: string): Promise<Invite[]> =>
|
||||
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||
approveInvite: (id: number) =>
|
||||
req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
|
||||
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
|
||||
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
|
||||
addInvite: (email: string) =>
|
||||
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
@@ -1511,10 +1591,8 @@ export const api = {
|
||||
req(`/api/me/notifications?include_dismissed=${includeDismissed}`),
|
||||
notificationUnreadCount: (): Promise<{ count: number }> =>
|
||||
req("/api/me/notifications/unread_count"),
|
||||
markNotificationRead: (id: number) =>
|
||||
req(`/api/me/notifications/${id}/read`, { method: "POST" }),
|
||||
markAllNotificationsRead: () =>
|
||||
req("/api/me/notifications/read_all", { method: "POST" }),
|
||||
markNotificationRead: (id: number) => req(`/api/me/notifications/${id}/read`, { method: "POST" }),
|
||||
markAllNotificationsRead: () => req("/api/me/notifications/read_all", { method: "POST" }),
|
||||
dismissNotification: (id: number) =>
|
||||
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
||||
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
|
||||
@@ -1563,19 +1641,17 @@ export const api = {
|
||||
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
||||
updateDownloadProfile: (
|
||||
id: number,
|
||||
patch: { name?: string; spec?: DownloadSpec }
|
||||
patch: { name?: string; spec?: DownloadSpec },
|
||||
): Promise<DownloadProfile> =>
|
||||
req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteDownloadProfile: (id: number) =>
|
||||
req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
|
||||
deleteDownloadProfile: (id: number) => req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
|
||||
|
||||
enqueueDownload: (body: {
|
||||
source: string;
|
||||
profile_id?: number;
|
||||
spec?: DownloadSpec;
|
||||
display_name?: string;
|
||||
}): Promise<DownloadJob> =>
|
||||
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||
enqueueEdit: (body: {
|
||||
source_job_id: number;
|
||||
edit_spec: EditSpec;
|
||||
@@ -1606,7 +1682,7 @@ export const api = {
|
||||
display_uploader?: string;
|
||||
display_uploader_url?: string;
|
||||
extra_links?: string[];
|
||||
}
|
||||
},
|
||||
): Promise<DownloadJob> =>
|
||||
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
|
||||
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
|
||||
@@ -1619,12 +1695,12 @@ export const api = {
|
||||
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
|
||||
createDownloadLink: (
|
||||
jobId: number,
|
||||
body: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
||||
body: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||
): Promise<ShareLink> =>
|
||||
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateDownloadLink: (
|
||||
linkId: number,
|
||||
patch: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
||||
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||
): Promise<ShareLink> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
||||
@@ -1651,7 +1727,9 @@ export const api = {
|
||||
req(`/api/admin/downloads/quota/${userId}`),
|
||||
adminSetDownloadQuota: (
|
||||
userId: number,
|
||||
patch: Partial<Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">>
|
||||
patch: Partial<
|
||||
Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">
|
||||
>,
|
||||
): Promise<AdminDownloadQuota> =>
|
||||
req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
|
||||
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
||||
|
||||
@@ -48,7 +48,8 @@ export function initDock(uid: number): void {
|
||||
let restored: DockChat[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey()!);
|
||||
if (raw) restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
|
||||
if (raw)
|
||||
restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
|
||||
} catch {
|
||||
restored = [];
|
||||
}
|
||||
@@ -60,17 +61,20 @@ export function openChat(partner: PartnerLike): void {
|
||||
const existing = chats.find((c) => c.partnerId === partner.id);
|
||||
if (existing) {
|
||||
// Re-opening focuses it: ensure expanded and move to the front.
|
||||
emit([
|
||||
{ ...existing, minimized: false },
|
||||
...chats.filter((c) => c.partnerId !== partner.id),
|
||||
]);
|
||||
emit([{ ...existing, minimized: false }, ...chats.filter((c) => c.partnerId !== partner.id)]);
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
[
|
||||
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 0 },
|
||||
{
|
||||
partnerId: partner.id,
|
||||
name: partner.name,
|
||||
avatar: partner.avatar_url,
|
||||
minimized: false,
|
||||
flash: 0,
|
||||
},
|
||||
...chats,
|
||||
].slice(0, MAX_OPEN)
|
||||
].slice(0, MAX_OPEN),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,9 +85,15 @@ export function notifyIncoming(partner: PartnerLike): void {
|
||||
if (!existing) {
|
||||
emit(
|
||||
[
|
||||
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 1 },
|
||||
{
|
||||
partnerId: partner.id,
|
||||
name: partner.name,
|
||||
avatar: partner.avatar_url,
|
||||
minimized: false,
|
||||
flash: 1,
|
||||
},
|
||||
...chats,
|
||||
].slice(0, MAX_OPEN)
|
||||
].slice(0, MAX_OPEN),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -105,6 +115,6 @@ export function useDockChats(): DockChat[] {
|
||||
return () => subs.delete(cb);
|
||||
},
|
||||
() => chats,
|
||||
() => chats
|
||||
() => chats,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ const INLINE_RE =
|
||||
|
||||
function tsToSeconds(ts: string): number {
|
||||
const parts = ts.split(":").map((n) => parseInt(n, 10));
|
||||
return parts.length === 3
|
||||
? parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
: parts[0] * 60 + parts[1];
|
||||
return parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
|
||||
@@ -26,9 +24,7 @@ function parseStart(t: string | null): number | null {
|
||||
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||
if (!m) return null;
|
||||
const total =
|
||||
parseInt(m[1] || "0", 10) * 3600 +
|
||||
parseInt(m[2] || "0", 10) * 60 +
|
||||
parseInt(m[3] || "0", 10);
|
||||
parseInt(m[1] || "0", 10) * 3600 + parseInt(m[2] || "0", 10) * 60 + parseInt(m[3] || "0", 10);
|
||||
return total || null;
|
||||
}
|
||||
|
||||
@@ -62,7 +58,7 @@ export function renderDescription(
|
||||
onSeek: (seconds: number) => void;
|
||||
onLoadVideo: (id: string, start: number | null) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
},
|
||||
): ReactNode[] {
|
||||
const { t } = opts;
|
||||
const out: ReactNode[] = [];
|
||||
@@ -70,7 +66,10 @@ export function renderDescription(
|
||||
const linkCls = "text-accent hover:underline break-all";
|
||||
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
|
||||
// (they're just noise in the popover), keeping single line breaks.
|
||||
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
|
||||
const clean = text
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
for (const chunk of clean.split(URL_RE)) {
|
||||
if (/^https?:\/\//.test(chunk)) {
|
||||
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
|
||||
@@ -87,13 +86,13 @@ export function renderDescription(
|
||||
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
|
||||
>
|
||||
{href}
|
||||
</button>
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
|
||||
{href}
|
||||
</a>
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
@@ -110,9 +109,10 @@ export function renderDescription(
|
||||
out.push(
|
||||
<a key={key++} href={`mailto:${email}`} className={linkCls}>
|
||||
{email}
|
||||
</a>
|
||||
</a>,
|
||||
);
|
||||
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
||||
if (m[1].length > email.length)
|
||||
out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
||||
} else if (m[2]) {
|
||||
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
|
||||
const tag = m[2].slice(1);
|
||||
@@ -125,7 +125,7 @@ export function renderDescription(
|
||||
className={linkCls}
|
||||
>
|
||||
{m[2]}
|
||||
</a>
|
||||
</a>,
|
||||
);
|
||||
} else {
|
||||
const ts = m[3];
|
||||
@@ -136,7 +136,7 @@ export function renderDescription(
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{ts}
|
||||
</button>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
|
||||
+44
-13
@@ -87,18 +87,22 @@ export async function clearDevice(userId: number): Promise<void> {
|
||||
|
||||
// --- key derivation ---
|
||||
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, ["deriveKey"]);
|
||||
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [
|
||||
"deriveKey",
|
||||
]);
|
||||
return subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: bsrc(salt), iterations: PBKDF2_ITERS, hash: "SHA-256" },
|
||||
base,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
async function importPrivate(pkcs8: ArrayBuffer): Promise<CryptoKey> {
|
||||
// Non-extractable: usable for deriveBits but can never be exported again.
|
||||
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
|
||||
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, [
|
||||
"deriveBits",
|
||||
]);
|
||||
}
|
||||
|
||||
// Try to restore the unlocked key from this device's storage. Returns true if unlocked.
|
||||
@@ -134,7 +138,11 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
|
||||
// wrapKey would be GCM nonce-reuse (leaks keystream + enables GHASH forgery). It is no longer a
|
||||
// verification oracle either — unlock() authenticates the passphrase via the wrapped-key GCM tag.
|
||||
const checkIv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok")));
|
||||
const keyCheck = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(checkIv) },
|
||||
wrapKey,
|
||||
bsrc(enc.encode("ok")),
|
||||
);
|
||||
const spki = await subtle.exportKey("spki", kp.publicKey);
|
||||
|
||||
await installKey(userId, await importPrivate(pkcs8));
|
||||
@@ -148,7 +156,11 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
|
||||
}
|
||||
|
||||
// Unlock on a device from the server blob; throws if the passphrase is wrong.
|
||||
export async function unlock(userId: number, passphrase: string, bundle: MyKeyBundle): Promise<void> {
|
||||
export async function unlock(
|
||||
userId: number,
|
||||
passphrase: string,
|
||||
bundle: MyKeyBundle,
|
||||
): Promise<void> {
|
||||
if (!bundle.wrapped_private_key || !bundle.salt || !bundle.wrap_iv || !bundle.key_check) {
|
||||
throw new Error("incomplete key bundle");
|
||||
}
|
||||
@@ -158,7 +170,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
|
||||
const pkcs8 = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
|
||||
wrapKey,
|
||||
bsrc(ub64(bundle.wrapped_private_key))
|
||||
bsrc(ub64(bundle.wrapped_private_key)),
|
||||
);
|
||||
await installKey(userId, await importPrivate(pkcs8));
|
||||
}
|
||||
@@ -168,15 +180,26 @@ async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<Cry
|
||||
const cached = convKeys.get(partnerId);
|
||||
if (cached) return cached;
|
||||
if (!privKey) throw new Error("locked");
|
||||
const pub = await subtle.importKey("spki", bsrc(ub64(partnerPubB64)), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
||||
const pub = await subtle.importKey(
|
||||
"spki",
|
||||
bsrc(ub64(partnerPubB64)),
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const bits = await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256);
|
||||
const hk = await subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
|
||||
const key = await subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: bsrc(new Uint8Array(0)), info: bsrc(enc.encode(HKDF_INFO)) },
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: bsrc(new Uint8Array(0)),
|
||||
info: bsrc(enc.encode(HKDF_INFO)),
|
||||
},
|
||||
hk,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
convKeys.set(partnerId, key);
|
||||
return key;
|
||||
@@ -185,11 +208,15 @@ async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<Cry
|
||||
export async function encryptFor(
|
||||
partnerId: number,
|
||||
partnerPubB64: string,
|
||||
plaintext: string
|
||||
plaintext: string,
|
||||
): Promise<{ ciphertext: string; iv: string }> {
|
||||
const key = await convKeyFor(partnerId, partnerPubB64);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(iv) }, key, bsrc(enc.encode(plaintext)));
|
||||
const ct = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(iv) },
|
||||
key,
|
||||
bsrc(enc.encode(plaintext)),
|
||||
);
|
||||
return { ciphertext: b64(ct), iv: b64(iv) };
|
||||
}
|
||||
|
||||
@@ -197,9 +224,13 @@ export async function decryptFrom(
|
||||
partnerId: number,
|
||||
partnerPubB64: string,
|
||||
ciphertext: string,
|
||||
iv: string
|
||||
iv: string,
|
||||
): Promise<string> {
|
||||
const key = await convKeyFor(partnerId, partnerPubB64);
|
||||
const pt = await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(iv)) }, key, bsrc(ub64(ciphertext)));
|
||||
const pt = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(ub64(iv)) },
|
||||
key,
|
||||
bsrc(ub64(ciphertext)),
|
||||
);
|
||||
return dec.decode(pt);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,7 @@ const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string;
|
||||
priority: { desc: "priority", asc: "priority_asc" },
|
||||
};
|
||||
|
||||
export const SORT_DEFAULT_DIR: Record<
|
||||
Exclude<SortKey, "shuffle" | "relevance">,
|
||||
"asc" | "desc"
|
||||
> = {
|
||||
export const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
|
||||
date: "desc",
|
||||
popular: "desc",
|
||||
duration: "desc",
|
||||
|
||||
@@ -34,17 +34,45 @@ export interface FeedViewSpec {
|
||||
|
||||
// The whole matrix in one place. Adding a view here makes TS point at every switch that needs it.
|
||||
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||
cards: { family: "card", minCol: 260, maxCol: null, thumb: true, dense: false, actionsBelow: false },
|
||||
cardsSmall: { family: "card", minCol: 180, maxCol: null, thumb: true, dense: true, actionsBelow: false },
|
||||
cards: {
|
||||
family: "card",
|
||||
minCol: 260,
|
||||
maxCol: null,
|
||||
thumb: true,
|
||||
dense: false,
|
||||
actionsBelow: false,
|
||||
},
|
||||
cardsSmall: {
|
||||
family: "card",
|
||||
minCol: 180,
|
||||
maxCol: null,
|
||||
thumb: true,
|
||||
dense: true,
|
||||
actionsBelow: false,
|
||||
},
|
||||
// Single column on purpose: the point of the list is that your eye runs down one edge with the
|
||||
// titles stacked. Density must not cost that — `tiles` is the multi-column option instead.
|
||||
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
|
||||
// dead space between them and the meta.
|
||||
rows: { family: "row", minCol: null, maxCol: 896, thumb: true, dense: false, actionsBelow: false },
|
||||
rows: {
|
||||
family: "row",
|
||||
minCol: null,
|
||||
maxCol: 896,
|
||||
thumb: true,
|
||||
dense: false,
|
||||
actionsBelow: false,
|
||||
},
|
||||
// Much wider (1472): everything sits on one line and the columns after the title are fixed, so
|
||||
// there's no gap for the extra width to stretch — it all goes to the title. Raised from 1280 at
|
||||
// the user's request once they saw how much room was left over with the rail and filters hidden.
|
||||
rowsCompact: { family: "row", minCol: null, maxCol: 1472, thumb: false, dense: false, actionsBelow: false },
|
||||
rowsCompact: {
|
||||
family: "row",
|
||||
minCol: null,
|
||||
maxCol: 1472,
|
||||
thumb: false,
|
||||
dense: false,
|
||||
actionsBelow: false,
|
||||
},
|
||||
// 380 = thumbnail (160) + gap (12) + ~192 for the text + the block's own padding (16). The text
|
||||
// floor is the action row's measured 156px plus enough for a title to be worth reading.
|
||||
// Tuned by measuring the real layouts, not guessed: 3 columns need 3n+32 px, so 380 turns the
|
||||
@@ -52,7 +80,14 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||
// (~955px) still gets 2. 470 was tried first and needed 956px for two — the common layout fell
|
||||
// back to ONE column and the mode looked broken; 400 then left 596px tiles whose titles ended
|
||||
// halfway, which is the very waste this mode exists to kill.
|
||||
tiles: { family: "row", minCol: 380, maxCol: null, thumb: true, dense: false, actionsBelow: true },
|
||||
tiles: {
|
||||
family: "row",
|
||||
minCol: 380,
|
||||
maxCol: null,
|
||||
thumb: true,
|
||||
dense: false,
|
||||
actionsBelow: true,
|
||||
},
|
||||
};
|
||||
|
||||
function isFeedView(v: unknown): v is FeedView {
|
||||
|
||||
@@ -26,7 +26,7 @@ const PLATFORMS: Record<string, string> = {
|
||||
// (to the platform's profile URL); the "<platform>: " label stays as text. Bare @handles
|
||||
// with no platform, and #hashtags, are left plain.
|
||||
const URL_SOURCE =
|
||||
'(?:https?://|www\\.)[^\\s<]+[^\\s<.,!?;:)\\]}"\']|[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.[a-z]{2,}/[^\\s<]*[^\\s<.,!?;:)\\]}"\']';
|
||||
"(?:https?://|www\\.)[^\\s<]+[^\\s<.,!?;:)\\]}\"']|[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.[a-z]{2,}/[^\\s<]*[^\\s<.,!?;:)\\]}\"']";
|
||||
const MENTION_SOURCE =
|
||||
"\\b(" +
|
||||
Object.keys(PLATFORMS).join("|") +
|
||||
|
||||
@@ -45,7 +45,11 @@ function open() {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data?.type === "message" && data.message) {
|
||||
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
|
||||
const e: IncomingMessage = {
|
||||
message: data.message as Message,
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
};
|
||||
for (const h of handlers) h(e);
|
||||
} else if (data?.type === "unread") {
|
||||
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
|
||||
|
||||
@@ -48,11 +48,35 @@ const MODULES: readonly ModuleDef[] = [
|
||||
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
|
||||
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
|
||||
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
|
||||
{ id: "messages", labelKey: "messages.navLabel", icon: MessageSquare, hasBadge: true, gate: notDemo },
|
||||
{ id: "downloads", labelKey: "downloads.navLabel", icon: Download, hasBadge: true, gate: notDemo },
|
||||
{
|
||||
id: "messages",
|
||||
labelKey: "messages.navLabel",
|
||||
icon: MessageSquare,
|
||||
hasBadge: true,
|
||||
gate: notDemo,
|
||||
},
|
||||
{
|
||||
id: "downloads",
|
||||
labelKey: "downloads.navLabel",
|
||||
icon: Download,
|
||||
hasBadge: true,
|
||||
gate: notDemo,
|
||||
},
|
||||
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 },
|
||||
{ id: "scheduler", labelKey: "header.account.scheduler", icon: Activity, system: true, gate: isAdmin },
|
||||
{ id: "config", labelKey: "header.account.config", icon: SlidersHorizontal, system: true, gate: isAdmin },
|
||||
{
|
||||
id: "scheduler",
|
||||
labelKey: "header.account.scheduler",
|
||||
icon: Activity,
|
||||
system: true,
|
||||
gate: isAdmin,
|
||||
},
|
||||
{
|
||||
id: "config",
|
||||
labelKey: "header.account.config",
|
||||
icon: SlidersHorizontal,
|
||||
system: true,
|
||||
gate: isAdmin,
|
||||
},
|
||||
{ id: "users", labelKey: "header.account.users", icon: Users, system: true, gate: isAdmin },
|
||||
{ id: "audit", labelKey: "header.account.audit", icon: ScrollText, system: true, gate: isAdmin },
|
||||
];
|
||||
@@ -69,9 +93,10 @@ const SETTINGS_MODULE: ModuleDef = {
|
||||
// place. If a Page is left unregistered, moduleDef()/moduleLabelKey[] return undefined and NavSidebar
|
||||
// throws on `.icon`; the coverage isn't compiler-enforced here because the ordered array can't also
|
||||
// be an exhaustive Page map, so treat the array as the checklist when the Page union grows.
|
||||
const BY_ID = Object.fromEntries(
|
||||
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m]),
|
||||
) as Record<Page, ModuleDef>;
|
||||
const BY_ID = Object.fromEntries([...MODULES, SETTINGS_MODULE].map((m) => [m.id, m])) as Record<
|
||||
Page,
|
||||
ModuleDef
|
||||
>;
|
||||
|
||||
// Look up a module's definition by page id (every Page is covered, incl. settings).
|
||||
export function moduleDef(id: Page): ModuleDef {
|
||||
|
||||
@@ -107,7 +107,9 @@ export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||
let audioCtx: AudioContext | null = null;
|
||||
function beep(): void {
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
const Ctx =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
audioCtx = audioCtx || new Ctx();
|
||||
if (audioCtx.state === "suspended") void audioCtx.resume();
|
||||
const osc = audioCtx.createOscillator();
|
||||
@@ -143,7 +145,13 @@ function load(): Notification[] {
|
||||
// live `action` callback, which can't survive serialization.
|
||||
return raw.map(
|
||||
(n) =>
|
||||
({ repeat: 1, ...(n as object), action: undefined, dismissed: true, transient: false }) as Notification
|
||||
({
|
||||
repeat: 1,
|
||||
...(n as object),
|
||||
action: undefined,
|
||||
dismissed: true,
|
||||
transient: false,
|
||||
}) as Notification,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -202,13 +210,14 @@ export function notify(input: NotifyInput): number {
|
||||
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
||||
// A transient status stays put until its producer clears it (no auto-dismiss timer),
|
||||
// same as an interaction-awaiting notice.
|
||||
const duration = requiresInteraction || input.transient
|
||||
? undefined
|
||||
: level === "error" || level === "fatal"
|
||||
? ERROR_TTL
|
||||
: config.durationMs > 0
|
||||
? config.durationMs
|
||||
: undefined;
|
||||
const duration =
|
||||
requiresInteraction || input.transient
|
||||
? undefined
|
||||
: level === "error" || level === "fatal"
|
||||
? ERROR_TTL
|
||||
: config.durationMs > 0
|
||||
? config.durationMs
|
||||
: undefined;
|
||||
|
||||
// Coalesce a rapid burst of identical notifications (same level+title+message within the
|
||||
// window) into one entry with a bumped repeat count, instead of appending a fresh copy each
|
||||
@@ -224,14 +233,14 @@ export function notify(input: NotifyInput): number {
|
||||
now - n.ts <= COALESCE_WINDOW_MS &&
|
||||
n.level === level &&
|
||||
(n.title ?? "") === (input.title ?? "") &&
|
||||
n.message === input.message
|
||||
n.message === input.message,
|
||||
);
|
||||
if (prior) {
|
||||
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
|
||||
items = items.map((n) =>
|
||||
n.id === prior.id
|
||||
? { ...n, ts: now, repeat: n.repeat + 1, dismissed: false, read: false, duration }
|
||||
: n
|
||||
: n,
|
||||
);
|
||||
emit();
|
||||
if (duration) setTimeout(() => dismiss(prior.id), duration);
|
||||
@@ -260,7 +269,10 @@ export function notify(input: NotifyInput): number {
|
||||
},
|
||||
];
|
||||
emit();
|
||||
if (config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal")) {
|
||||
if (
|
||||
config.sound &&
|
||||
(input.sound || requiresInteraction || level === "error" || level === "fatal")
|
||||
) {
|
||||
beep();
|
||||
}
|
||||
if (duration) setTimeout(() => dismiss(id), duration);
|
||||
@@ -301,7 +313,7 @@ export function resolveVideo(videoId: string): void {
|
||||
!(
|
||||
(n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched") &&
|
||||
n.meta.videoId === videoId
|
||||
)
|
||||
),
|
||||
);
|
||||
if (items.length !== before) emit();
|
||||
}
|
||||
|
||||
@@ -12,8 +12,20 @@ const v = (id: string, title: string, channel: string, secs: number | null = 60)
|
||||
({ id, title, channel_title: channel, duration_seconds: secs }) as Video;
|
||||
|
||||
const columns = [
|
||||
{ key: "title", header: "Title", render: () => null, sortable: true, sortValue: (x: Video) => x.title ?? "" },
|
||||
{ key: "channel", header: "Channel", render: () => null, sortable: true, sortValue: (x: Video) => x.channel_title ?? "" },
|
||||
{
|
||||
key: "title",
|
||||
header: "Title",
|
||||
render: () => null,
|
||||
sortable: true,
|
||||
sortValue: (x: Video) => x.title ?? "",
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: "Channel",
|
||||
render: () => null,
|
||||
sortable: true,
|
||||
sortValue: (x: Video) => x.channel_title ?? "",
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: "Length",
|
||||
|
||||
@@ -20,7 +20,7 @@ export function applyView<T extends Grouped>(
|
||||
rows: T[],
|
||||
columns: Sortable<T>[],
|
||||
sort: SortState,
|
||||
group: boolean
|
||||
group: boolean,
|
||||
): T[] {
|
||||
const sorted = sortRows(rows, columns, sort);
|
||||
if (!group) return sorted;
|
||||
|
||||
@@ -12,7 +12,8 @@ import { api } from "./api";
|
||||
|
||||
export function useDetailPrefs() {
|
||||
const qc = useQueryClient();
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
||||
{}) as {
|
||||
plexInfoArtBg?: boolean;
|
||||
plexInfoCast?: boolean;
|
||||
plexInfoRelated?: boolean;
|
||||
@@ -65,7 +66,11 @@ export function Filterable({
|
||||
}) {
|
||||
if (!onClick) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -145,9 +150,15 @@ export function DetailCustomizeMenu({
|
||||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{hasCast && <PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />}
|
||||
{hasCast && (
|
||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||
)}
|
||||
{hasRelated && onToggleRelated && (
|
||||
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefRelated")}
|
||||
on={showRelated ?? true}
|
||||
onClick={onToggleRelated}
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
@@ -156,15 +167,27 @@ export function DetailCustomizeMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
||||
export function PrefToggle({
|
||||
label,
|
||||
on,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}>
|
||||
<span className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`} />
|
||||
<span
|
||||
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -21,16 +21,16 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
"Playlists get real column headers — and sorting one no longer rewrites it behind your back.",
|
||||
features: [
|
||||
"The playlist view has sortable, filterable column headers: sort by title, channel or length, and filter by title or channel to find something in a long list.",
|
||||
"Sorting is now only a way of LOOKING at the list. Your playlist's own order stays exactly as you saved it until you press \"Save this order\" — so trying a sort can no longer rewrite the list, or leave a YouTube-synced playlist marked as unsynced. Saving is undoable like any other reorder.",
|
||||
"The list tells you which view you are in: whether the current sort matches your saved order, differs from it and can be saved, or is filtered (where saving would drop the hidden videos, so it is not offered). The \"#\" column always shows each video's position in the SAVED order.",
|
||||
'Sorting is now only a way of LOOKING at the list. Your playlist\'s own order stays exactly as you saved it until you press "Save this order" — so trying a sort can no longer rewrite the list, or leave a YouTube-synced playlist marked as unsynced. Saving is undoable like any other reorder.',
|
||||
'The list tells you which view you are in: whether the current sort matches your saved order, differs from it and can be saved, or is filtered (where saving would drop the hidden videos, so it is not offered). The "#" column always shows each video\'s position in the SAVED order.',
|
||||
"Drag-reordering now works from the keyboard: focus a drag handle, press space, move with the arrow keys and press space again.",
|
||||
],
|
||||
fixes: [
|
||||
"Deleting a playlist synced with YouTube asked twice, and cancelling the second question still deleted it locally. It is one question now, with each outcome on its own button — and closing the dialog deletes nothing.",
|
||||
"Confirmation dialogs put the keyboard focus on Cancel when the action is destructive. Previously the confirm button took focus, so a stray Enter on an open dialog could run \"Delete user\" or \"Clear audit log\".",
|
||||
'Confirmation dialogs put the keyboard focus on Cancel when the action is destructive. Previously the confirm button took focus, so a stray Enter on an open dialog could run "Delete user" or "Clear audit log".',
|
||||
"Removing a video from a playlist now asks first, and keeps your undo history for the videos that remain.",
|
||||
"Typing in a table's column filter re-created the input on every keystroke, which moved the cursor and broke accented and IME input mid-word. This affected the channel manager, Discover and the audit log as well.",
|
||||
"A playlist that failed to load showed \"Loading…\" forever; it now says so and offers to retry. Failed reorders and renames are reported instead of passing silently.",
|
||||
'A playlist that failed to load showed "Loading…" forever; it now says so and offers to retry. Failed reorders and renames are reported instead of passing silently.',
|
||||
"Reordering a playlist no longer requires YouTube write access — it never needed it.",
|
||||
],
|
||||
chores: [
|
||||
@@ -60,12 +60,12 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
"The channel manager has a card view alongside the familiar table. Pick it from the switcher next to the tabs; your choice sticks, and both Subscriptions and Discover follow it.",
|
||||
"A new About column shows each channel's description, and hovering it opens the full profile — description, links, country, language, topics and keywords — without leaving the list. Links in descriptions are clickable, and social handles resolve to their profiles.",
|
||||
"Sorting is shared between the two views: sort by clicking a column header in the table or from the dropdown in cards, and your choice carries over when you switch.",
|
||||
"Both views page through your channels — \"Rows per page\" in the table, \"Cards per page\" in cards — and the table now has a pager at the bottom as well as the top. Turning a page takes you back to the top of the list.",
|
||||
'Both views page through your channels — "Rows per page" in the table, "Cards per page" in cards — and the table now has a pager at the bottom as well as the top. Turning a page takes you back to the top of the list.',
|
||||
"Filter the table column by column: funnels on priority, channel, sync state and tags narrow it without disturbing your top-level filters.",
|
||||
"Moving between the manager's tabs now goes into your browser history, so Back steps back through the tabs you visited instead of leaving the manager altogether.",
|
||||
],
|
||||
fixes: [
|
||||
"The channel table stays centred with \"Rows per page\" set to All. A single long channel name used to stretch its column and push the whole table off to one side.",
|
||||
'The channel table stays centred with "Rows per page" set to All. A single long channel name used to stretch its column and push the whole table off to one side.',
|
||||
"The tag editor no longer opens behind the card below it.",
|
||||
],
|
||||
},
|
||||
@@ -82,8 +82,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.45.0",
|
||||
date: "2026-07-19",
|
||||
summary:
|
||||
"Read the feed the way you like it, and explore your Plex library by cast.",
|
||||
summary: "Read the feed the way you like it, and explore your Plex library by cast.",
|
||||
features: [
|
||||
"The feed has five view modes now — large cards, small cards, rows, compact rows, and a multi-column tile view. Pick the density that suits you from the switcher next to the sort control; your choice sticks.",
|
||||
"Explore your Plex library by the people in it: click any cast member on a film or show's page to see everything they're in. Pick several and, by default, you'll get titles featuring any of them — flip to \"All\" to require every one. The actors you've picked now show as cards above the grid, each with a photo and a title count, and a click to drop them.",
|
||||
@@ -181,9 +180,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.39.1",
|
||||
date: "2026-07-12",
|
||||
fixes: [
|
||||
"Signing out and back in no longer disconnects your YouTube access.",
|
||||
],
|
||||
fixes: ["Signing out and back in no longer disconnects your YouTube access."],
|
||||
},
|
||||
{
|
||||
version: "0.39.0",
|
||||
@@ -197,9 +194,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
version: "0.38.0",
|
||||
date: "2026-07-12",
|
||||
summary: "The Plex filter sidebar can now be collapsed, plus a large internal cleanup.",
|
||||
fixes: [
|
||||
"The Plex library no longer stops loading more titles after you open one and go back.",
|
||||
],
|
||||
fixes: ["The Plex library no longer stops loading more titles after you open one and go back."],
|
||||
},
|
||||
{
|
||||
version: "0.37.0",
|
||||
@@ -223,9 +218,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.36.4",
|
||||
date: "2026-07-10",
|
||||
chores: [
|
||||
"Internal in-app player cleanup.",
|
||||
],
|
||||
chores: ["Internal in-app player cleanup."],
|
||||
},
|
||||
{
|
||||
version: "0.36.1",
|
||||
@@ -259,7 +252,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
date: "2026-07-09",
|
||||
summary: "Polish: the Plex watch-sync switch now responds instantly.",
|
||||
fixes: [
|
||||
"The Plex watch-sync switch now flips the moment you click it and shows an \"Importing…\" indicator while it works, instead of appearing to do nothing until the import finished.",
|
||||
'The Plex watch-sync switch now flips the moment you click it and shows an "Importing…" indicator while it works, instead of appearing to do nothing until the import finished.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -267,7 +260,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
date: "2026-07-09",
|
||||
summary: "Fix: turning on Plex watch sync could show a server error.",
|
||||
fixes: [
|
||||
"Enabling Plex watch sync could briefly show a \"Server error\" if the switch was toggled again while the first import was still running. The import is now safe to run more than once, and the switch is disabled while it works. (If you saw the error, your Plex history still imported correctly.)",
|
||||
'Enabling Plex watch sync could briefly show a "Server error" if the switch was toggled again while the first import was still running. The import is now safe to run more than once, and the switch is disabled while it works. (If you saw the error, your Plex history still imported correctly.)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -332,23 +325,22 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.31.0",
|
||||
date: "2026-07-07",
|
||||
summary: "Richer downloads — edit details, clickable channels & links, and prettier share-link previews.",
|
||||
summary:
|
||||
"Richer downloads — edit details, clickable channels & links, and prettier share-link previews.",
|
||||
features: [
|
||||
"Downloads: the edit (pencil) button now edits the full details — title, channel name and a channel link, plus any number of extra reference links. The channel and links appear on the download card as clickable links, and a source that carries a channel (like YouTube or Facebook) fills the channel link in automatically.",
|
||||
"Downloads: shared watch pages now show the correct title, channel and links — including your own edits — with every link clickable. (Renaming a download is now reflected on its share link too.)",
|
||||
"Shared links now unfurl richly in chat apps like Messenger, with the video's title, channel and thumbnail instead of a blank card. Videos without a thumbnail still show a clean text card.",
|
||||
],
|
||||
chores: [
|
||||
"Shorter, clearer site description for search engines and link previews.",
|
||||
],
|
||||
chores: ["Shorter, clearer site description for search engines and link previews."],
|
||||
},
|
||||
{
|
||||
version: "0.30.0",
|
||||
date: "2026-07-06",
|
||||
summary: "Plex playlists — your own ordered watch-lists.",
|
||||
features: [
|
||||
"Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page (\"Add to playlist\"), then find your playlists in the Plex sidebar.",
|
||||
"Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit \"Play all\" to watch straight through — the player steps through the whole list in order, auto-advancing to the next.",
|
||||
'Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page ("Add to playlist"), then find your playlists in the Plex sidebar.',
|
||||
'Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit "Play all" to watch straight through — the player steps through the whole list in order, auto-advancing to the next.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -387,7 +379,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.28.0",
|
||||
date: "2026-07-06",
|
||||
summary: "Plex collections — browse them, filter by them, and see a title's collection right on its info page.",
|
||||
summary:
|
||||
"Plex collections — browse them, filter by them, and see a title's collection right on its info page.",
|
||||
features: [
|
||||
"Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.",
|
||||
"Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.",
|
||||
@@ -399,7 +392,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.27.0",
|
||||
date: "2026-07-06",
|
||||
summary: "Search Plex by cast & crew — find every film an actor or director is in, with clickable person cards.",
|
||||
summary:
|
||||
"Search Plex by cast & crew — find every film an actor or director is in, with clickable person cards.",
|
||||
features: [
|
||||
"Plex search: the top search box now matches cast and crew names, not just titles — search “drew” and Drew Barrymore's films come up first (ranked above films that merely mention the word).",
|
||||
"Plex search: matching actors and directors appear as person cards (photo + how many titles) above the results — click one to filter the library to their films and add them to the sidebar filter.",
|
||||
@@ -408,7 +402,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.26.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
||||
summary:
|
||||
"Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
||||
features: [
|
||||
"Plex library: a greatly expanded filter panel for movies — genre, IMDb rating, year range, length, when it was added to Plex, and age rating — plus new sort orders (release date, year, IMDb rating, length) with an ascending/descending toggle.",
|
||||
"Plex filters: pick several genres and choose Any (match one) or All (match every one); stack multiple actors, directors and studios at once (from the info page).",
|
||||
@@ -422,7 +417,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.25.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.",
|
||||
summary:
|
||||
"Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.",
|
||||
features: [
|
||||
"Plex: every movie/episode now has a media info page (the “i” button on a card) with the poster, IMDb score + a link to IMDb, content rating, genres, director, a full cast list with photos, and the synopsis — plus Play/Resume and watch controls.",
|
||||
"Plex player: press “I” (or the info button) for the same rich info as an overlay, without stopping playback.",
|
||||
@@ -433,7 +429,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.24.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Plex player polish: a Stop button, a keyboard help overlay, time-remaining and a clock, and Back returns you to your place in the library.",
|
||||
summary:
|
||||
"Plex player polish: a Stop button, a keyboard help overlay, time-remaining and a clock, and Back returns you to your place in the library.",
|
||||
features: [
|
||||
"Plex player: added an explicit Stop button, a time-remaining readout next to the seek bar, and a live clock in the top corner (both fade with the controls).",
|
||||
"Plex player: press “H” (or the keyboard button) for an on-screen cheat sheet of all shortcuts.",
|
||||
@@ -446,7 +443,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.23.2",
|
||||
date: "2026-07-05",
|
||||
summary: "The player now steps through the exact list it was opened with, so watching along no longer breaks Loop and Next/Previous.",
|
||||
summary:
|
||||
"The player now steps through the exact list it was opened with, so watching along no longer breaks Loop and Next/Previous.",
|
||||
fixes: [
|
||||
"Player: it now plays through a frozen snapshot of the filtered, sorted list it was launched with. Previously, as each video was auto-marked watched it dropped out of a filtered feed, silently shrinking the queue mid-playback — which jumped playback to an unrelated video, and stopped Loop “All” from ever wrapping back to the first video once earlier ones were watched. Next/Previous and both Loop modes now work against the original list for the whole session.",
|
||||
],
|
||||
@@ -477,7 +475,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.22.3",
|
||||
date: "2026-07-04",
|
||||
summary: "Step through the feed in the player, vertical videos fit, and downloads show their source.",
|
||||
summary:
|
||||
"Step through the feed in the player, vertical videos fit, and downloads show their source.",
|
||||
features: [
|
||||
"In-app player: step to the previous/next video in the feed's order — click the arrows beside the player or press Shift+←/→ (plain ←/→ still seek within the video).",
|
||||
"Player auto-advance and loop, saved to your account: when a video ends, play the next, previous or a random one — and loop just that video or the whole list. Works for players opened from the feed and from playlists.",
|
||||
@@ -506,7 +505,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.22.1",
|
||||
date: "2026-07-04",
|
||||
summary: "Downloaded and shared videos now play in every browser, including on iPhone and iPad.",
|
||||
summary:
|
||||
"Downloaded and shared videos now play in every browser, including on iPhone and iPad.",
|
||||
fixes: [
|
||||
"Downloads now use widely-compatible video (H.264) and audio (AAC) so a saved file — and a public watch link — plays everywhere, including iOS/Safari, where the previous format left a broken player. Existing downloads keep their old format; re-download to get the compatible version.",
|
||||
],
|
||||
@@ -514,7 +514,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.22.0",
|
||||
date: "2026-07-04",
|
||||
summary: "The Download Center — save videos to the server, edit them into clips, and share them.",
|
||||
summary:
|
||||
"The Download Center — save videos to the server, edit them into clips, and share them.",
|
||||
features: [
|
||||
"Download Center: save any video (or a search result) to the server with yt-dlp, laid out Plex-style with a poster + info file. Choose a built-in format preset or make your own, keep an eye on your per-user storage quota, and save the finished file to your device.",
|
||||
"Built-in video editor: trim, crop, and cut a download into segments — keep the parts you want as separate clips or join them into one file. Pick a precise (frame-accurate) or fast cut, with a scrub timeline and a hover preview.",
|
||||
@@ -528,7 +529,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.21.0",
|
||||
date: "2026-07-02",
|
||||
summary: "Run a different account in each browser tab, a roomier collapsible layout, and nav polish.",
|
||||
summary:
|
||||
"Run a different account in each browser tab, a roomier collapsible layout, and nav polish.",
|
||||
features: [
|
||||
"Multi-account, per tab: open two tabs and sign into a different account in each — each tab stays on its own account. Switching or adding an account in one tab no longer changes what the others show. Every tab keeps its own filters, theme and settings.",
|
||||
"Roomier layout: the filter sidebar is now a full-height column you can collapse like the left nav (a thin rail keeps the active-filter count). Your video counts and sync status moved to the top of the left rail, and the Mine/Library switch sits with the filters. Both panels remember whether you left them open or collapsed.",
|
||||
@@ -544,9 +546,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
version: "0.20.2",
|
||||
date: "2026-07-01",
|
||||
summary: "A pop-up flash fix, and an open-source link on the sign-in page.",
|
||||
features: [
|
||||
"Siftlode is open source — the sign-in page now links to its public source code.",
|
||||
],
|
||||
features: ["Siftlode is open source — the sign-in page now links to its public source code."],
|
||||
fixes: [
|
||||
"Opening one pop-up from inside another — for example Release Notes from the About box — no longer makes it flash and vanish instantly. It opens and stays put, and Back closes it as expected.",
|
||||
],
|
||||
@@ -591,7 +591,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.18.0",
|
||||
date: "2026-06-29",
|
||||
summary: "Search all of YouTube from your feed, and new keyboard/scroll controls in the video player.",
|
||||
summary:
|
||||
"Search all of YouTube from your feed, and new keyboard/scroll controls in the video player.",
|
||||
features: [
|
||||
"Live YouTube search: type a search and press Enter (or the YouTube button) to search all of YouTube right from the feed — results show up as normal cards you can play, save and add to playlists. They're kept out of your regular Library by default; a Source filter lets you show or hide search-found videos. Shorts and currently-live/upcoming results are left out automatically.",
|
||||
"Live search uses no YouTube API quota by default, so it's no longer limited to a handful of searches per day. (Admins can switch back to the official API under Configuration → Quota if the quota-free source ever stops working.)",
|
||||
@@ -665,8 +666,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.14.0",
|
||||
date: "2026-06-21",
|
||||
summary:
|
||||
"Run your own Siftlode: a guided first-run setup and a one-command self-host package.",
|
||||
summary: "Run your own Siftlode: a guided first-run setup and a one-command self-host package.",
|
||||
features: [
|
||||
"A first-run setup wizard: the very first time a fresh instance is opened, a guided wizard walks you through creating the first admin account and (optionally) entering your Google and email credentials — no editing config files by hand. Once finished, the instance is marked configured and the wizard steps aside.",
|
||||
"Google sign-in is now optional: if an instance hasn't been given Google credentials, the Google buttons and YouTube-account features are simply hidden, so a self-hoster can run the app with email-and-password accounts only.",
|
||||
@@ -762,7 +762,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.9.0",
|
||||
date: "2026-06-18",
|
||||
summary: "A notifications inbox, and automatic clean-up of videos that can no longer be played.",
|
||||
summary:
|
||||
"A notifications inbox, and automatic clean-up of videos that can no longer be played.",
|
||||
features: [
|
||||
"New Notifications center in the left sidebar, with an unread badge: one place for both durable system updates (about your library and background jobs) and your recent activity (with quick Unhide / Unwatch / Find-in-feed actions). Mark items read, dismiss them individually, or clear everything. Pop-up toasts still appear as before.",
|
||||
"Automatic library maintenance: a background job spots videos that can no longer be played anywhere — deleted, made private, or premieres that never went live — hides them from your feed right away, and removes them after a grace period. Genuine 24/7 livestreams are left alone.",
|
||||
@@ -788,7 +789,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.7.0",
|
||||
date: "2026-06-17",
|
||||
summary: "A redesigned, sortable Channel manager — plus a reorganized sidebar and several fixes.",
|
||||
summary:
|
||||
"A redesigned, sortable Channel manager — plus a reorganized sidebar and several fixes.",
|
||||
features: [
|
||||
"Channel manager is now a sortable, filterable table: sort by any column, filter channels by name or tag straight from the headers, choose how many rows per page (or show them all), and jump to any page. Your sorting, filters and page are remembered across reloads.",
|
||||
"Channel rows: open a channel on YouTube (middle-click the name or the ↗ icon), a single “fully synced” badge once a channel is fully caught up, and instant tag toggling. Admins can reset a channel to re-fetch it from scratch.",
|
||||
@@ -871,8 +873,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.2.0",
|
||||
date: "2026-06-15",
|
||||
summary:
|
||||
"Browse beyond your own subscriptions, and a much smarter filter sidebar.",
|
||||
summary: "Browse beyond your own subscriptions, and a much smarter filter sidebar.",
|
||||
features: [
|
||||
"Shared library: a new Mine / Library switch in the header lets you browse every channel the community has added — not just your own subscriptions — and even works without granting YouTube access. Your watch, save and hide state always stays private to you.",
|
||||
"Smarter filter chips: topic and language chips now show live counts for your current view and hide the ones that no longer match anything, so narrowing down is quicker. Chips are ordered by size, and topics have a clear Any / All match toggle (All = channels that have all the selected topics).",
|
||||
|
||||
@@ -80,7 +80,10 @@ export function activeAccountId(): number | null {
|
||||
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
|
||||
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
|
||||
* of touching another account's data. */
|
||||
export function accountKey(base: string, id: number | null | undefined = activeAccountId()): string | null {
|
||||
export function accountKey(
|
||||
base: string,
|
||||
id: number | null | undefined = activeAccountId(),
|
||||
): string | null {
|
||||
return id != null ? `${base}.${id}` : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function clearedFilters(current: FeedFilters): FeedFilters {
|
||||
*/
|
||||
export function useActiveFilters(
|
||||
filters: FeedFilters,
|
||||
setFilters: (f: FeedFilters) => void
|
||||
setFilters: (f: FeedFilters) => void,
|
||||
): ActiveFilter[] {
|
||||
const { t } = useTranslation();
|
||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||
|
||||
@@ -25,8 +25,7 @@ export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
|
||||
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
|
||||
}, [enabled, page, totalPages]);
|
||||
|
||||
const paged =
|
||||
!enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
|
||||
const paged = !enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
|
||||
|
||||
return {
|
||||
paged,
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useLiveQuery<T>(
|
||||
// (e.g. poll faster while a job is running). react-query re-evaluates it after each fetch
|
||||
// against fresh data — the right place for this, vs. a React state toggle that can lag or
|
||||
// stall the live updates.
|
||||
opts: { intervalMs?: number | ((data: T | undefined) => number); enabled?: boolean } = {}
|
||||
opts: { intervalMs?: number | ((data: T | undefined) => number); enabled?: boolean } = {},
|
||||
) {
|
||||
const { intervalMs = 4000, enabled = true } = opts;
|
||||
return useQuery({
|
||||
|
||||
@@ -10,7 +10,10 @@ import { useEffect, useState, type CSSProperties } from "react";
|
||||
// `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a
|
||||
// table's sticky header): fading there would eat into the header instead of hinting at content, and
|
||||
// the header would visibly change as the fade turns on with the first scroll.
|
||||
export function useScrollFade(fadePx = 20, fadeTop = true): {
|
||||
export function useScrollFade(
|
||||
fadePx = 20,
|
||||
fadeTop = true,
|
||||
): {
|
||||
ref: (node: HTMLElement | null) => void;
|
||||
style: CSSProperties;
|
||||
} {
|
||||
@@ -43,8 +46,10 @@ export function useScrollFade(fadePx = 20, fadeTop = true): {
|
||||
// every child of a long list on each append.
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const rec of records) {
|
||||
for (const node of Array.from(rec.addedNodes)) if (node instanceof Element) ro.observe(node);
|
||||
for (const node of Array.from(rec.removedNodes)) if (node instanceof Element) ro.unobserve(node);
|
||||
for (const node of Array.from(rec.addedNodes))
|
||||
if (node instanceof Element) ro.observe(node);
|
||||
for (const node of Array.from(rec.removedNodes))
|
||||
if (node instanceof Element) ro.unobserve(node);
|
||||
}
|
||||
update();
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ interface History<T> {
|
||||
|
||||
export function useUndoable<T>(
|
||||
initial: T,
|
||||
opts?: { onApply?: (value: T) => void; limit?: number }
|
||||
opts?: { onApply?: (value: T) => void; limit?: number },
|
||||
): Undoable<T> {
|
||||
const [, force] = useReducer((n: number) => n + 1, 0);
|
||||
const ref = useRef<History<T>>({ past: [], present: initial, future: [] });
|
||||
@@ -48,7 +48,7 @@ export function useUndoable<T>(
|
||||
force();
|
||||
onApplyRef.current?.(next);
|
||||
},
|
||||
[limit]
|
||||
[limit],
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user