A related video played in place is the player's item now: its title and its own watched / playlist / download controls, its progress checkpointed and resumed, marked watched at its end — and its end does not step the queue. Back returns to the opened video, where it was left. The related list: - its marks announce through the shared notice (the Notification Center finds them), join the host's player session (the feed refetches on close), and stay per write, so an earlier answer no longer undoes a later mark still on its way; - every host's state write patches the cached related lists, which are never refetched; - kept for the session (gcTime), a retry asks for the page that failed, a list read so far with nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names itself on hover. Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons are left out. Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the top layer; the help sheet lists the list keys over a layer that holds a list.
80 lines
3.7 KiB
TypeScript
80 lines
3.7 KiB
TypeScript
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
|
|
// app version, and preference save. Only touches core's req.
|
|
import { req } from "./core";
|
|
import type { DebugSession, DiagnosticsState } from "./diagnostics";
|
|
|
|
export interface Me {
|
|
id: number;
|
|
email: string;
|
|
display_name: string | null;
|
|
avatar_url: string | null;
|
|
role: string;
|
|
/** The latest sign-in on any device (ISO), or null when none is recorded. */
|
|
last_login_at: string | null;
|
|
has_google: boolean;
|
|
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
|
|
* comparison, so a surface can render this whenever it is present. Someone who registered as
|
|
* `me+siftlode@gmail.com` keeps that address; this is the other one that also signs them in. */
|
|
google_email: string | null;
|
|
has_password: boolean;
|
|
google_enabled: boolean;
|
|
plex_enabled: boolean;
|
|
search_source: "scrape" | "api";
|
|
/** Whether live YouTube search / related videos are on: an admin turns either off with a daily
|
|
* limit of 0, and what would only be refused is then left out. */
|
|
search_enabled: boolean;
|
|
related_enabled: boolean;
|
|
can_read: boolean;
|
|
can_write: boolean;
|
|
pending_invites: number;
|
|
/** Admin-only: sign-ups an admin still has to let in — approved BEFORE they registered, so nothing
|
|
* ever reached the Access-requests queue and its badge read zero (roadmap UX-25). Counted apart
|
|
* from pending_invites because they are different facts, and widening one number silently would
|
|
* change what an existing field means. */
|
|
pending_activations: number;
|
|
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
|
|
email_configured: boolean;
|
|
preferences: Record<string, unknown>;
|
|
/** The consented diagnostics level in force and when it lapses. */
|
|
diagnostics: DiagnosticsState;
|
|
/** The debug session recording right now, if any — a reload resumes it. */
|
|
debug_session: DebugSession | null;
|
|
/** Where a bug report goes — the tracker's repository — or null: download only. */
|
|
issues: { repo: string } | null;
|
|
}
|
|
|
|
/** The `nav.role.*` key an account is shown under. */
|
|
export function roleKey(a: Pick<Me, "role">): "admin" | "user" {
|
|
return a.role === "admin" ? "admin" : "user";
|
|
}
|
|
|
|
/** What an account is called on screen: its display name, else the local part of its email. */
|
|
export function displayName(a: { display_name: string | null; email: string }): string {
|
|
return a.display_name ?? a.email.split("@")[0]!;
|
|
}
|
|
|
|
export interface VersionInfo {
|
|
app_version: string;
|
|
git_sha: string;
|
|
build_date: string | null;
|
|
db_revision: string | null;
|
|
}
|
|
|
|
export const accountApi = {
|
|
// Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
|
|
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
|
|
// logs a failed request to the console). React Query treats the null as data, not an error.
|
|
me: async (): Promise<Me | null> => {
|
|
// Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
|
|
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
|
|
return r.authenticated ? (r as Me) : null;
|
|
},
|
|
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
|
|
// Ends this browser's session.
|
|
logout: (): Promise<{ ok: boolean }> => req("/auth/logout", { method: "POST" }),
|
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
|
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
|
|
savePrefs: (p: Record<string, unknown>) =>
|
|
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
|
|
};
|