Focus on content
This commit is contained in:
Executable
+287
@@ -0,0 +1,287 @@
|
||||
/** Recent public pushes performed by eric, across owners. Not a repository/authorship census.
|
||||
* Standalone public-data client: no credentials, storage, avatars, HTML sinks or raw feed logging.
|
||||
*/
|
||||
export const ORIGIN = 'https://gitea.eom.dev';
|
||||
export const PAGE_SIZE = 10;
|
||||
export const MAX_PAGES = 15;
|
||||
export const MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const MAX_CONTENT_BYTES = 64 * 1024;
|
||||
export const REFRESH_MS = 15 * 60 * 1000;
|
||||
const ENDPOINT = `${ORIGIN}/api/v1/users/eric/activities/feeds`;
|
||||
const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const isID = value => Number.isSafeInteger(value) && value > 0;
|
||||
const safeSegment = value => typeof value === 'string' && value.length <= 100 &&
|
||||
/^[A-Za-z0-9_.-]+$/.test(value) && !/[\r\n]/.test(value) && value !== '.' && value !== '..';
|
||||
const text = (value, max) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
||||
|
||||
/** Check the raw string BEFORE URL parsing can erase traversal, whitespace or port 443. */
|
||||
export function validateRepositoryURL(value, fullName) {
|
||||
if (typeof fullName !== 'string' || fullName.length > 201) return null;
|
||||
const parts = fullName.split('/');
|
||||
if (parts.length !== 2 || !parts.every(safeSegment)) return null;
|
||||
if (typeof value !== 'string' || /[\s\\%?#\x00-\x1f\x7f]/.test(value) ||
|
||||
value !== `${ORIGIN}/${fullName}`) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && url.origin === ORIGIN && !url.username &&
|
||||
!url.password && !url.port && !url.hash && !url.search ? url.href : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Gitea activity.created is RFC3339. Reject impossible calendars and future timestamps. */
|
||||
export function normalizeDate(value, { now = Date.now() } = {}) {
|
||||
if (typeof value !== 'string' || value.length > 40 || !Number.isFinite(now)) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
||||
if (!match || /[\r\n]/.test(value)) return null;
|
||||
const [, year, month, day, hour, minute, second, zone] = match;
|
||||
if (+hour > 23 || +minute > 59 || +second > 59 ||
|
||||
(zone !== 'Z' && (+zone.slice(1, 3) > 23 || +zone.slice(4) > 59))) return null;
|
||||
const check = new Date(0);
|
||||
check.setUTCFullYear(+year, +month - 1, +day);
|
||||
if (check.getUTCFullYear() !== +year || check.getUTCMonth() !== +month - 1 || check.getUTCDate() !== +day) return null;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) && timestamp <= now + 60_000 ? new Date(timestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
function commitMetadata(content, url, description) {
|
||||
let head;
|
||||
// Byte-bound embedded JSON as well as the outer response. Never copy authors/emails/CompareURL.
|
||||
if (typeof content === 'string' && content.length <= MAX_CONTENT_BYTES &&
|
||||
new TextEncoder().encode(content).byteLength <= MAX_CONTENT_BYTES) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (isRecord(parsed) && isRecord(parsed.HeadCommit)) head = parsed.HeadCommit;
|
||||
} catch { /* Malformed embedded JSON falls back to the public repository description. */ }
|
||||
}
|
||||
const summary = typeof head?.Message === 'string'
|
||||
? text(head.Message.split(/[\r\n]/, 1)[0], 200) : '';
|
||||
// Sha1 is an opaque Git identifier here, not a cryptographic security primitive.
|
||||
const hash = typeof head?.Sha1 === 'string' && /^(?:[\da-fA-F]{40}|[\da-fA-F]{64})$/.test(head.Sha1) &&
|
||||
!/[\r\n]/.test(head.Sha1) ? head.Sha1.toLowerCase() : null;
|
||||
return { summary: summary || description.slice(0, 200), commitURL: hash ? `${url}/commit/${hash}` : null };
|
||||
}
|
||||
|
||||
/** Strict public/actor gates; sort by push time, deduplicate repo.id, keep the newest three. */
|
||||
export function normalizeActivities(events, { now = Date.now() } = {}) {
|
||||
if (!Array.isArray(events) || events.length > PAGE_SIZE * MAX_PAGES) throw new Error('Invalid activity list');
|
||||
const projects = [];
|
||||
for (const event of events) {
|
||||
if (!isRecord(event) || !isID(event.id) || event.op_type !== 'commit_repo' ||
|
||||
event.is_private !== false || !isRecord(event.act_user) || event.act_user.login !== 'eric' ||
|
||||
!isRecord(event.repo) || event.repo.private !== false || !isID(event.repo.id)) continue;
|
||||
const repo = event.repo;
|
||||
const url = validateRepositoryURL(repo.html_url, repo.full_name);
|
||||
const created = normalizeDate(event.created, { now });
|
||||
if (!url || !created || repo.name !== repo.full_name.split('/')[1]) continue;
|
||||
const description = text(repo.description, 240);
|
||||
projects.push({ id: repo.id, fullName: repo.full_name, url, created, description,
|
||||
...commitMetadata(event.content, url, description), eventID: event.id });
|
||||
}
|
||||
projects.sort((a, b) => Date.parse(b.created) - Date.parse(a.created) || b.eventID - a.eventID ||
|
||||
compareText(a.fullName, b.fullName) || compareText(a.summary, b.summary));
|
||||
const seen = new Set();
|
||||
return projects.filter(project => !seen.has(project.id) && seen.add(project.id)).slice(0, 3)
|
||||
.map(({ eventID, ...project }) => project);
|
||||
}
|
||||
|
||||
const abortError = () => new DOMException('Public activity request aborted', 'AbortError');
|
||||
|
||||
/** Fixed endpoint only. Timer covers headers AND streamed body, even with non-cooperative mocks. */
|
||||
export async function fetchActivityPage(page, {
|
||||
fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES
|
||||
} = {}) {
|
||||
if (!Number.isInteger(page) || page < 1 || page > MAX_PAGES) throw new Error('Invalid activity page');
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 ||
|
||||
!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
|
||||
const url = `${ENDPOINT}?only-performed-by=true&limit=${PAGE_SIZE}&page=${page}`;
|
||||
const controller = new AbortController();
|
||||
let reader;
|
||||
let rejectAbort;
|
||||
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
||||
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
const timer = setTimeout(cancel, timeoutMs);
|
||||
const work = async () => {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const response = await fetchImpl(url, { method: 'GET', signal: controller.signal,
|
||||
credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', cache: 'no-store',
|
||||
headers: { Accept: 'application/json' } });
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (!response.ok || response.redirected || (response.url && response.url !== url)) throw new Error('Public activity request failed');
|
||||
if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
|
||||
throw new Error('Unexpected content type');
|
||||
}
|
||||
const length = response.headers.get('content-length');
|
||||
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large');
|
||||
const totalHeader = response.headers.get('x-total-count');
|
||||
const total = totalHeader === null ? null : Number(totalHeader);
|
||||
if (totalHeader !== null && (!/^\d+$/.test(totalHeader) || !Number.isSafeInteger(total) || total < 0)) {
|
||||
throw new Error('Invalid activity total');
|
||||
}
|
||||
if (!response.body?.getReader) throw new Error('Streaming response required');
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let body = '';
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) throw new Error('Response too large');
|
||||
body += decoder.decode(value, { stream: true });
|
||||
}
|
||||
const events = JSON.parse(body + decoder.decode());
|
||||
if (!Array.isArray(events) || events.length > PAGE_SIZE) throw new Error('Invalid activity page body');
|
||||
return { events, total };
|
||||
};
|
||||
try { return await Promise.race([work(), aborted]); }
|
||||
finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
controller.abort();
|
||||
if (reader) void Promise.resolve(reader.cancel()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** A bounded window, not all history. Any required page failure rejects the entire refresh. */
|
||||
export async function fetchRecentProjects(dependencies = {}) {
|
||||
const now = typeof dependencies.now === 'function' ? dependencies.now() : Date.now();
|
||||
const events = [];
|
||||
let total = null;
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const result = await fetchActivityPage(page, dependencies);
|
||||
if (page === 1) total = result.total; // Optional feed event count; never a project count.
|
||||
events.push(...result.events);
|
||||
const items = normalizeActivities(events, { now });
|
||||
if (items.length >= 3 || result.events.length < PAGE_SIZE || page === MAX_PAGES) {
|
||||
return { items, pages: page, scannedEvents: events.length, total };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function element(document, tag, className, content) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (content !== undefined) node.textContent = content;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Safe DOM only. Validate normalized inputs again at the public rendering boundary. */
|
||||
export function createCard(document, project) {
|
||||
const url = validateRepositoryURL(project?.url, project?.fullName);
|
||||
const created = normalizeDate(project?.created);
|
||||
if (!url || !created) throw new Error('Invalid project card');
|
||||
const card = element(document, 'article', 'project-card');
|
||||
card.append(element(document, 'h3', '', project.fullName));
|
||||
const description = text(project.description, 240);
|
||||
const summary = text(project.summary, 200);
|
||||
if (description) card.append(element(document, 'p', 'project-description', description));
|
||||
if (summary) card.append(element(document, 'p', 'project-summary', summary));
|
||||
const time = element(document, 'time', 'project-meta', `Public push · ${new Date(created).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC'
|
||||
})}`);
|
||||
time.dateTime = created;
|
||||
card.append(time);
|
||||
const links = element(document, 'div', 'project-links');
|
||||
const explore = element(document, 'a', 'text-link', 'Explore repository →');
|
||||
explore.href = url;
|
||||
explore.rel = 'noopener noreferrer';
|
||||
explore.referrerPolicy = 'no-referrer';
|
||||
links.append(explore);
|
||||
const commitURL = project.commitURL;
|
||||
const hash = typeof commitURL === 'string' && commitURL.startsWith(`${url}/commit/`)
|
||||
? commitURL.slice(`${url}/commit/`.length) : '';
|
||||
if (/^(?:[\da-f]{40}|[\da-f]{64})$/.test(hash) && !/[\r\n]/.test(hash)) {
|
||||
const commit = element(document, 'a', 'text-link', 'View this commit');
|
||||
commit.href = `${url}/commit/${hash}`;
|
||||
commit.rel = 'noopener noreferrer';
|
||||
commit.referrerPolicy = 'no-referrer';
|
||||
links.append(commit);
|
||||
}
|
||||
card.append(links);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** DOM contract: #gitea-projects > [data-project-items], [data-project-status].
|
||||
* The grid/fallback and permanent activity-heading link belong to the host markup.
|
||||
*/
|
||||
export function initGiteaProjects(document = globalThis.document, {
|
||||
fetchImpl = globalThis.fetch, now = Date.now,
|
||||
setIntervalImpl = globalThis.setInterval, clearIntervalImpl = globalThis.clearInterval,
|
||||
...requestBounds
|
||||
} = {}) {
|
||||
const root = document?.querySelector('#gitea-projects');
|
||||
const items = root?.querySelector('[data-project-items]');
|
||||
const status = root?.querySelector('[data-project-status]');
|
||||
if (!items || !status) return { ready: Promise.resolve(), refresh: async () => {}, stop() {} };
|
||||
status.setAttribute('role', 'status');
|
||||
let stopped = false;
|
||||
let generation = 0;
|
||||
let pending;
|
||||
let interval;
|
||||
let checkedAt;
|
||||
let needsRefresh = true;
|
||||
const previous = () => checkedAt === undefined ? 'showing selected projects.' : 'showing previously checked results.';
|
||||
const invalidate = () => { generation++; pending?.abort(); pending = undefined; };
|
||||
const refresh = async () => {
|
||||
if (stopped || document.hidden) return;
|
||||
invalidate();
|
||||
const current = generation;
|
||||
const controller = new AbortController();
|
||||
pending = controller;
|
||||
needsRefresh = true;
|
||||
status.textContent = `Checking recent public pushes; ${previous()}`;
|
||||
try {
|
||||
const result = await fetchRecentProjects({ ...requestBounds, fetchImpl, now, signal: controller.signal });
|
||||
if (stopped || document.hidden || current !== generation) return;
|
||||
const cards = result.items.map(project => createCard(document, project));
|
||||
if (!cards.length) cards.push(element(document, 'p', 'project-empty', 'No recent public pushes found.'));
|
||||
items.replaceChildren(...cards);
|
||||
checkedAt = now();
|
||||
needsRefresh = false;
|
||||
status.textContent = `Recent public pushes · Checked ${new Date(checkedAt).toISOString()} · ${result.scannedEvents} recent activity events checked (up to 150; not all history).`;
|
||||
} catch {
|
||||
if (!stopped && !document.hidden && current === generation) {
|
||||
status.textContent = `Recent public pushes unavailable; ${previous()}`;
|
||||
}
|
||||
} finally {
|
||||
if (current === generation) pending = undefined;
|
||||
}
|
||||
};
|
||||
const pauseTimer = () => { if (interval !== undefined) clearIntervalImpl(interval); interval = undefined; };
|
||||
const startTimer = () => {
|
||||
if (!stopped && !document.hidden && interval === undefined) interval = setIntervalImpl(() => {
|
||||
if (!pending) void refresh();
|
||||
}, REFRESH_MS);
|
||||
};
|
||||
const visibility = () => {
|
||||
if (stopped) return;
|
||||
if (document.hidden) {
|
||||
pauseTimer();
|
||||
invalidate();
|
||||
status.textContent = `Recent public pushes paused; ${previous()}`;
|
||||
} else {
|
||||
startTimer();
|
||||
if (needsRefresh || checkedAt === undefined || now() - checkedAt >= REFRESH_MS) void refresh();
|
||||
else status.textContent = `Recent public pushes · Previously checked ${new Date(checkedAt).toISOString()}.`;
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
startTimer();
|
||||
if (document.hidden) status.textContent = 'Recent public pushes paused; showing selected projects.';
|
||||
const ready = refresh();
|
||||
return { ready, refresh, stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
pauseTimer();
|
||||
invalidate();
|
||||
document.removeEventListener('visibilitychange', visibility);
|
||||
} };
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initGiteaProjects(), { once: true });
|
||||
else initGiteaProjects();
|
||||
}
|
||||
+184
-6
@@ -217,6 +217,7 @@ main {
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
@@ -353,7 +354,9 @@ main {
|
||||
}
|
||||
|
||||
.intro-section,
|
||||
.featured-section {
|
||||
.featured-section,
|
||||
.films-section,
|
||||
.services-section {
|
||||
max-width: 1600px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -414,6 +417,23 @@ main {
|
||||
border-top: 3px solid var(--blue);
|
||||
}
|
||||
|
||||
.path-game {
|
||||
border-top: 3px solid var(--red);
|
||||
}
|
||||
|
||||
.path-grid,
|
||||
.gaming-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gaming-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gaming-grid .project-card:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.path-play {
|
||||
border-top: 3px solid var(--peach);
|
||||
}
|
||||
@@ -446,6 +466,156 @@ main {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.film-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--page-deep);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.media-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 2rem);
|
||||
padding: 0.45rem 0.8rem;
|
||||
color: #cdd6f4;
|
||||
background: #181825;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.media-copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.35rem, 2.5vw, 2rem);
|
||||
}
|
||||
|
||||
.media-copy > .text-link {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.music-section {
|
||||
display: block;
|
||||
background: var(--page-deep);
|
||||
border-block: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.music-copy {
|
||||
max-width: 650px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.music-copy > p:not(.eyebrow) {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.music-feature {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.1rem;
|
||||
}
|
||||
|
||||
.latest-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.feed-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.feed-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.media-card .media-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.media-copy time {
|
||||
margin-block: 0.25rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.collection-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.8rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.collection-controls label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.collection-controls select {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.8rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.collection-controls select:focus-visible {
|
||||
outline: 3px solid var(--peach);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.workshop-recordings {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.project-card h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.project-summary {
|
||||
padding-left: 0.8rem;
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.project-empty {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.service-mark img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
@@ -621,6 +791,7 @@ main {
|
||||
}
|
||||
|
||||
.support-copy {
|
||||
min-width: 0;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
@@ -749,13 +920,13 @@ main {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.home-menu .pure-menu-item:nth-child(3) {
|
||||
.home-menu .nav-about {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
min-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 4.5rem 1.35rem 3.5rem;
|
||||
}
|
||||
|
||||
@@ -794,7 +965,9 @@ main {
|
||||
}
|
||||
|
||||
.path-grid,
|
||||
.project-grid {
|
||||
.project-grid,
|
||||
.film-grid,
|
||||
.music-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -835,7 +1008,7 @@ main {
|
||||
}
|
||||
|
||||
.support-section {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
padding: 3.5rem 1.25rem;
|
||||
}
|
||||
@@ -855,7 +1028,12 @@ main {
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.home-menu .pure-menu-item:nth-child(2) {
|
||||
.home-menu .pure-menu-link {
|
||||
padding-inline: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.home-menu .pure-menu-item:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+420
@@ -0,0 +1,420 @@
|
||||
/** Public PeerTube publications. No cookies, embeds, HTML sinks or persistent storage. */
|
||||
export const ORIGIN = 'https://peertube.eom.dev';
|
||||
export const ORIGINALS_UUID = 'bde0cf4d-318f-4865-b205-b11104a7c46b';
|
||||
export const MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const REFRESH_MS = 15 * 60 * 1000;
|
||||
const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
||||
const uuidPattern = new RegExp(`^${UUID}$`);
|
||||
const watchPattern = new RegExp(`^/(?:w/[A-Za-z0-9]+|videos/watch/${UUID})$`);
|
||||
const CHANNEL_NAME = '[A-Za-z][A-Za-z0-9_.-]{0,99}';
|
||||
const isChannelName = value => typeof value === 'string' && new RegExp(`^${CHANNEL_NAME}$`).test(value) && !/[\r\n]/.test(value);
|
||||
|
||||
/** Reject ambiguous input BEFORE URL normalization can erase traversal or an explicit port. */
|
||||
export function validateOriginURL(value) {
|
||||
if (typeof value !== 'string' || value.length > 2048 || /[\s\\%<>"'\x00-\x1f\x7f]/.test(value)) return null;
|
||||
if (!value.startsWith('/') && !value.startsWith(`${ORIGIN}/`)) return null;
|
||||
if (value.startsWith('//') || /(?:^|\/)\.{1,2}(?:\/|[?#]|$)/.test(value)) return null;
|
||||
try {
|
||||
const url = new URL(value, ORIGIN);
|
||||
if (url.origin !== ORIGIN || url.username || url.password || url.port || url.hash) return null;
|
||||
return url;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function validateWatchURL(value) {
|
||||
const url = validateOriginURL(value);
|
||||
return url && !url.search && watchPattern.test(url.pathname) ? url.href : null;
|
||||
}
|
||||
|
||||
export function validateThumbnailURL(value) {
|
||||
const url = validateOriginURL(value);
|
||||
return url && !url.search && /^\/(?:lazy-static|static)\/thumbnails\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.(?:jpg|jpeg|png|webp|avif)$/i.test(url.pathname) ? url.href : null;
|
||||
}
|
||||
|
||||
function text(value, fallback = '') {
|
||||
return typeof value === 'string' ? value.trim().slice(0, 500) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeDate(value, { now = Date.now(), maxFutureMs = Infinity } = {}) {
|
||||
if (typeof value !== 'string' || value.length > 100 || !value.trim()) return null;
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp) || timestamp > now + maxFutureMs) return null;
|
||||
// Date.parse normalizes impossible ISO/RFC calendar dates (e.g. February 30).
|
||||
const calendar = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
const rfc = /^(?:[A-Za-z]{3},\s*)?(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})\s/i.exec(value);
|
||||
if (!calendar && !rfc) return null;
|
||||
const year = +(calendar ? calendar[1] : rfc[3]);
|
||||
const month = calendar ? +calendar[2] - 1 : ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'].indexOf(rfc[2].toLowerCase());
|
||||
const day = +(calendar ? calendar[3] : rfc[1]);
|
||||
const check = new Date(0);
|
||||
check.setUTCFullYear(year, month, day);
|
||||
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month || check.getUTCDate() !== day) return null;
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
||||
|
||||
/** URL identity deliberately does not guess that a short ID equals a UUID. */
|
||||
export function sortAndDedupe(items, limit = 3, dateOptions) {
|
||||
if (!Array.isArray(items) || items.length > 1000) throw new Error('Too many publications');
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 1000) throw new Error('Invalid publication limit');
|
||||
const normalized = items.flatMap(item => {
|
||||
if (!item || typeof item !== 'object') return [];
|
||||
const url = validateWatchURL(item.url);
|
||||
const publishedAt = normalizeDate(item.publishedAt, dateOptions);
|
||||
if (!url || !publishedAt) return [];
|
||||
return [{ url, publishedAt, title: text(item.title, 'Untitled video') || 'Untitled video',
|
||||
source: text(item.source, 'PeerTube'), thumbnail: validateThumbnailURL(item.thumbnail) }];
|
||||
});
|
||||
normalized.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt) || compareText(a.url, b.url) || compareText(a.title, b.title) || compareText(a.source, b.source));
|
||||
const seen = new Set();
|
||||
return normalized.filter(item => !seen.has(item.url) && seen.add(item.url)).slice(0, limit);
|
||||
}
|
||||
|
||||
export function normalizeJSONVideos(entries, source = 'PeerTube', limit = 3, dateOptions) {
|
||||
if (!Array.isArray(entries) || entries.length > 1000) throw new Error('Invalid video list');
|
||||
return sortAndDedupe(entries.flatMap(entry => {
|
||||
const video = entry?.video ?? entry;
|
||||
if (!video || typeof video !== 'object' || (video.privacy !== undefined && video.privacy?.id !== 1)) return [];
|
||||
// Validate the supplied URL first: UUID metadata must never rescue an unsafe origin/path.
|
||||
const originalURL = validateWatchURL(video.url);
|
||||
if (!originalURL) return [];
|
||||
const url = typeof video.uuid === 'string' && uuidPattern.test(video.uuid)
|
||||
? `${ORIGIN}/videos/watch/${video.uuid.toLowerCase()}` : originalURL;
|
||||
return [{ url, title: video.name ?? video.title, publishedAt: video.publishedAt,
|
||||
thumbnail: video.thumbnailPath, source }];
|
||||
}), limit, dateOptions);
|
||||
}
|
||||
|
||||
function abortError(message = 'Request aborted') { return new DOMException(message, 'AbortError'); }
|
||||
|
||||
/** The timer covers both headers and streamed body, including non-cooperative injected fetches. */
|
||||
export async function fetchText(url, { fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES, shouldFetch = () => true } = {}) {
|
||||
const safe = validateOriginURL(url);
|
||||
if (!safe || !safe.pathname.startsWith('/api/v1/')) throw new Error('Invalid request');
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 || !Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
|
||||
const controller = new AbortController();
|
||||
let reader;
|
||||
let timer;
|
||||
let rejectAbort;
|
||||
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
||||
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
timer = setTimeout(cancel, timeoutMs);
|
||||
const work = async () => {
|
||||
if (signal?.aborted || !shouldFetch()) throw abortError();
|
||||
const response = await fetchImpl(safe.href, { signal: controller.signal, credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
|
||||
headers: { Accept: 'application/json' } });
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (!response.ok || response.redirected || (response.url && response.url !== safe.href)) throw new Error('PeerTube request failed');
|
||||
const mime = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
||||
if (mime !== 'application/json') throw new Error('Unexpected content type');
|
||||
const length = response.headers.get('content-length');
|
||||
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large');
|
||||
// Refuse unstreamable bodies rather than allocate an unbounded response.text().
|
||||
if (!response.body?.getReader) throw new Error('Streaming response required');
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let result = '';
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (controller.signal.aborted) throw abortError();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) throw new Error('Response too large');
|
||||
result += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return result + decoder.decode();
|
||||
};
|
||||
try { return await Promise.race([work(), aborted]); }
|
||||
finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
controller.abort();
|
||||
if (reader) void reader.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJSON(url, dependencies) {
|
||||
return JSON.parse(await fetchText(url, dependencies));
|
||||
}
|
||||
|
||||
export function channelVideosURL(name, { count = 100, start = 0 } = {}) {
|
||||
if (!isChannelName(name)) throw new Error('Invalid channel name');
|
||||
if (!Number.isInteger(count) || count < 1 || count > 100 || !Number.isSafeInteger(start) || start < 0 || start > 1000) throw new Error('Invalid channel pagination');
|
||||
return `${ORIGIN}/api/v1/video-channels/${name}/videos?count=${count}&start=${start}&sort=-publishedAt`;
|
||||
}
|
||||
|
||||
function validateAPIPage(result, count, maxTotal = Number.MAX_SAFE_INTEGER) {
|
||||
if (!result || !Number.isSafeInteger(result.total) || result.total < 0 || result.total > maxTotal ||
|
||||
!Array.isArray(result.data) || result.data.length > count || result.data.length > result.total ||
|
||||
result.data.some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry))) throw new Error('Invalid API page');
|
||||
}
|
||||
|
||||
export async function fetchChannel(name, source = 'PeerTube', dependencies = {}) {
|
||||
// The server sorts before limiting: even a large channel needs just one latest page.
|
||||
const result = await fetchJSON(channelVideosURL(name, { count: 3 }), dependencies);
|
||||
validateAPIPage(result, 3);
|
||||
if (result.data.length !== Math.min(result.total, 3)) throw new Error('Incomplete API page');
|
||||
return normalizeJSONVideos(result.data, source, 3, dependencies.dateOptions);
|
||||
}
|
||||
|
||||
/** Every page is checked. A changing/inconsistent total is a failure, not a partial library. */
|
||||
export async function fetchPaginated(path, dependencies = {}) {
|
||||
const channelPath = typeof path === 'string' && /^\/api\/v1\/video-channels\/([^/]+)\/videos$/.exec(path);
|
||||
if (!/^\/api\/v1\/accounts\/eric\/(?:video-playlists|video-channels)$/.test(path) &&
|
||||
!(channelPath && isChannelName(channelPath[1])) &&
|
||||
!new RegExp(`^/api/v1/video-playlists/${UUID}/videos$`).test(path)) throw new Error('Invalid API endpoint');
|
||||
const data = [];
|
||||
let total;
|
||||
for (let page = 0; page < 10; page++) {
|
||||
const url = path.startsWith('/api/v1/video-channels/')
|
||||
? channelVideosURL(path.split('/')[4], { start: data.length })
|
||||
: `${ORIGIN}${path}?count=100&start=${data.length}`;
|
||||
const result = await fetchJSON(url, dependencies);
|
||||
validateAPIPage(result, 100, 1000);
|
||||
if (total !== undefined && total !== result.total) throw new Error('API changed during pagination');
|
||||
total = result.total;
|
||||
if (data.length + result.data.length > total) throw new Error('Inconsistent API total');
|
||||
data.push(...result.data);
|
||||
if (data.length === total) return data;
|
||||
if (!result.data.length) throw new Error('Incomplete API page');
|
||||
}
|
||||
throw new Error('API pagination limit exceeded');
|
||||
}
|
||||
|
||||
export function normalizeCollections(channels, playlists) {
|
||||
if (!Array.isArray(channels) || !Array.isArray(playlists) || channels.length > 1000 || playlists.length > 1000) throw new Error('Invalid collections');
|
||||
const unique = values => {
|
||||
const seen = new Set();
|
||||
return values.filter(value => !seen.has(value.key) && seen.add(value.key))
|
||||
.sort((a, b) => a.title.localeCompare(b.title) || a.key.localeCompare(b.key));
|
||||
};
|
||||
return {
|
||||
channels: unique(channels.flatMap(channel => /^[1-9]\d{0,9}$/.test(String(channel?.id)) && isChannelName(channel?.name)
|
||||
? [{ key: `channel:${channel.id}`, type: 'channel', id: channel.name, title: text(channel.displayName ?? channel.name), link: `${ORIGIN}/video-channels/${channel.name}/videos` }] : [])),
|
||||
playlists: unique(playlists.flatMap(playlist => playlist?.privacy?.id === 1 && uuidPattern.test(playlist?.uuid) && typeof playlist.displayName === 'string'
|
||||
? [{ key: `playlist:${playlist.uuid.toLowerCase()}`, type: 'playlist', id: playlist.uuid.toLowerCase(), title: text(playlist.displayName), link: `${ORIGIN}/video-playlists/${playlist.uuid.toLowerCase()}` }] : []))
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverCollections(dependencies = {}) {
|
||||
const [channels, playlists] = await Promise.all([
|
||||
fetchPaginated('/api/v1/accounts/eric/video-channels', dependencies),
|
||||
fetchPaginated('/api/v1/accounts/eric/video-playlists', dependencies)
|
||||
]);
|
||||
return normalizeCollections(channels, playlists);
|
||||
}
|
||||
|
||||
export async function fetchCollection(collection, dependencies = {}) {
|
||||
if (collection?.type === 'channel') return fetchChannel(collection.id, collection.title, dependencies);
|
||||
if (collection?.type !== 'playlist' || !uuidPattern.test(collection.id)) throw new Error('Invalid collection');
|
||||
return normalizeJSONVideos(await fetchPaginated(`/api/v1/video-playlists/${collection.id}/videos`, dependencies), collection.title);
|
||||
}
|
||||
|
||||
export async function loadRegion(names, source, dependencies = {}) {
|
||||
if (!Array.isArray(names) || !names.length || names.length > 20 || names.some(name => !isChannelName(name))) throw new Error('Invalid feed channels');
|
||||
const results = await Promise.allSettled(names.map(name => fetchChannel(name, source, dependencies)));
|
||||
const successes = results.filter(result => result.status === 'fulfilled');
|
||||
return { ok: successes.length > 0, unavailable: results.length - successes.length,
|
||||
items: sortAndDedupe(successes.flatMap(result => result.value), 3) };
|
||||
}
|
||||
|
||||
function element(document, tag, className, content) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (content !== undefined) node.textContent = content;
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createCard(document, item) {
|
||||
const url = validateWatchURL(item.url);
|
||||
const publishedAt = normalizeDate(item.publishedAt);
|
||||
if (!url || !publishedAt) throw new Error('Invalid card');
|
||||
const title = text(item.title, 'Untitled video');
|
||||
const card = element(document, 'article', 'project-card media-card');
|
||||
const media = element(document, 'a', 'media-image');
|
||||
media.href = url;
|
||||
media.setAttribute('aria-label', `Watch ${title}`);
|
||||
const thumbnail = validateThumbnailURL(item.thumbnail);
|
||||
if (thumbnail) {
|
||||
const image = element(document, 'img');
|
||||
image.src = thumbnail;
|
||||
image.alt = '';
|
||||
image.loading = 'lazy';
|
||||
image.width = 560;
|
||||
image.height = 315;
|
||||
image.referrerPolicy = 'no-referrer';
|
||||
image.addEventListener('error', () => { image.hidden = true; }, { once: true });
|
||||
media.append(image);
|
||||
}
|
||||
media.append(element(document, 'span', 'media-label', 'Watch ↗'));
|
||||
const copy = element(document, 'div', 'media-copy');
|
||||
const date = element(document, 'time', '', new Date(publishedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }));
|
||||
date.dateTime = publishedAt;
|
||||
const link = element(document, 'a', 'text-link', 'Watch on PeerTube →');
|
||||
link.href = url;
|
||||
copy.append(element(document, 'p', 'card-kicker', text(item.source, 'PeerTube')), element(document, 'h3', '', title), date, link);
|
||||
card.append(media, copy);
|
||||
return card;
|
||||
}
|
||||
|
||||
export function renderItems(document, container, items) {
|
||||
const cards = items.map(item => createCard(document, item));
|
||||
container.replaceChildren(...(cards.length ? cards : [element(document, 'p', 'feed-empty', 'No public uploads in this collection yet.')]));
|
||||
}
|
||||
|
||||
function checkedStatus(now) { return `Latest uploads · checked ${new Date(now()).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; }
|
||||
|
||||
/** Selection content has its own abort controller AND generation guard; select stays enabled. */
|
||||
export function createLibraryController(document, root, dependencies = {}) {
|
||||
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
|
||||
const select = root.querySelector('#peertube-collection');
|
||||
const items = root.querySelector('[data-library-items]');
|
||||
const status = root.querySelector('[data-library-status]');
|
||||
const link = root.querySelector('[data-library-link]');
|
||||
const now = dependencies.now ?? Date.now;
|
||||
let collections = new Map();
|
||||
let generation = 0;
|
||||
let contentController;
|
||||
let discoveryController;
|
||||
let discoveryBusy = false;
|
||||
let stopped = false;
|
||||
let contentBusy = false;
|
||||
const selectCollection = async key => {
|
||||
if (stopped || document.hidden) return;
|
||||
const collection = collections.get(key);
|
||||
if (!collection) return;
|
||||
contentController?.abort();
|
||||
contentController = new AbortController();
|
||||
const version = ++generation;
|
||||
contentBusy = true;
|
||||
// Do not show cards from a previous collection under a new collection label.
|
||||
items.replaceChildren();
|
||||
link.href = collection.link;
|
||||
link.hidden = false;
|
||||
link.textContent = collection.type === 'playlist' ? 'Browse playlist on PeerTube →' : 'Browse channel on PeerTube →';
|
||||
status.textContent = `Loading ${collection.title}…`;
|
||||
try {
|
||||
const publications = await (dependencies.fetchCollection ?? fetchCollection)(collection, { ...dependencies, signal: contentController.signal });
|
||||
if (stopped || document.hidden || version !== generation) return;
|
||||
renderItems(document, items, publications);
|
||||
status.textContent = publications.length ? checkedStatus(now) : 'No public uploads in this collection yet.';
|
||||
} catch {
|
||||
if (!stopped && version === generation) status.textContent = 'Live publications unavailable. Browse this collection on PeerTube.';
|
||||
} finally { if (version === generation) contentBusy = false; }
|
||||
};
|
||||
const discover = async () => {
|
||||
if (stopped || document.hidden || discoveryBusy) return;
|
||||
discoveryBusy = true;
|
||||
discoveryController = new AbortController();
|
||||
select.disabled = true;
|
||||
status.textContent = 'Loading channels and playlists…';
|
||||
try {
|
||||
const result = await (dependencies.discoverCollections ?? discoverCollections)({ ...dependencies, signal: discoveryController.signal });
|
||||
if (stopped || document.hidden) return;
|
||||
const previous = select.value;
|
||||
collections = new Map([...result.channels, ...result.playlists].map(collection => [collection.key, collection]));
|
||||
const groups = [];
|
||||
for (const [label, values] of [['Channels', result.channels], ['Playlists', result.playlists]]) {
|
||||
if (!values.length) continue;
|
||||
const group = element(document, 'optgroup');
|
||||
group.label = label;
|
||||
for (const collection of values) {
|
||||
const option = element(document, 'option', '', collection.title);
|
||||
option.value = collection.key;
|
||||
group.append(option);
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
select.replaceChildren(...groups);
|
||||
if (!collections.size) {
|
||||
contentController?.abort();
|
||||
generation++;
|
||||
items.replaceChildren(element(document, 'p', 'feed-empty', 'No public channels or playlists found.'));
|
||||
status.textContent = 'No public channels or playlists found.';
|
||||
link.href = `${ORIGIN}/accounts/eric/videos`;
|
||||
link.textContent = 'Browse all publications';
|
||||
link.hidden = false;
|
||||
return;
|
||||
}
|
||||
select.value = collections.has(previous) ? previous : collections.has(`playlist:${ORIGINALS_UUID}`) ? `playlist:${ORIGINALS_UUID}` : result.playlists[0]?.key ?? result.channels[0].key;
|
||||
select.disabled = false;
|
||||
await selectCollection(select.value);
|
||||
} catch {
|
||||
if (!stopped) {
|
||||
select.disabled = collections.size === 0;
|
||||
status.textContent = 'Live library discovery unavailable. Browse publications on PeerTube.';
|
||||
link.hidden = false;
|
||||
}
|
||||
} finally { discoveryBusy = false; }
|
||||
};
|
||||
const onChange = () => { void selectCollection(select.value); };
|
||||
select.addEventListener('change', onChange);
|
||||
return { discover, selectCollection, refresh: async () => { if (!discoveryBusy && !contentBusy) await discover(); },
|
||||
pause() { generation++; contentBusy = false; contentController?.abort(); discoveryController?.abort(); },
|
||||
stop() { stopped = true; generation++; contentController?.abort(); discoveryController?.abort(); select.removeEventListener('change', onChange); } };
|
||||
}
|
||||
|
||||
/** Returns {ready, refresh, stop, library}; all timers/listeners are removable for tests. */
|
||||
export function initPeerTubeFeeds(document, dependencies = {}) {
|
||||
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const regions = Array.from(document.querySelectorAll('[data-peertube-feed]')).map(root => ({ root, busy: false, controller: null, hasLive: false }));
|
||||
const libraryRoot = document.querySelector('#peertube-library');
|
||||
const library = libraryRoot ? createLibraryController(document, libraryRoot, dependencies) : null;
|
||||
let stopped = false;
|
||||
let lastRefresh = -Infinity;
|
||||
const refreshRegion = async region => {
|
||||
if (region.busy || stopped || document.hidden) return;
|
||||
const status = region.root.querySelector('[data-feed-status]');
|
||||
const items = region.root.querySelector('[data-feed-items]');
|
||||
if (!status || !items) return;
|
||||
region.busy = true;
|
||||
const controller = region.controller = new AbortController();
|
||||
status.textContent = 'Checking latest uploads…';
|
||||
try {
|
||||
const names = (region.root.getAttribute('data-channels') ?? '').split(',').map(name => name.trim());
|
||||
const result = await (dependencies.loadRegion ?? loadRegion)(names, region.root.getAttribute('data-title') ?? 'PeerTube', { ...dependencies, signal: controller.signal });
|
||||
if (stopped || document.hidden || controller.signal.aborted) return;
|
||||
if (!result.ok) { status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; return; }
|
||||
renderItems(document, items, result.items);
|
||||
region.hasLive = true;
|
||||
status.textContent = result.unavailable ? 'Some channels unavailable · showing available uploads.' : result.items.length ? checkedStatus(now) : 'No public uploads in these channels yet.';
|
||||
} catch { if (!stopped && !document.hidden && !controller.signal.aborted) status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; }
|
||||
finally { if (region.controller === controller) region.busy = false; }
|
||||
};
|
||||
const refresh = async ({ force = false } = {}) => {
|
||||
if (stopped || document.hidden || (!force && now() - lastRefresh < REFRESH_MS)) return;
|
||||
lastRefresh = now();
|
||||
await Promise.all([...regions.map(refreshRegion), library?.refresh()]);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (!document.hidden) void refresh();
|
||||
else {
|
||||
for (const region of regions) {
|
||||
if (region.busy) lastRefresh = -Infinity;
|
||||
region.controller?.abort();
|
||||
region.busy = false;
|
||||
}
|
||||
library?.pause();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
const interval = (dependencies.setIntervalImpl ?? setInterval)(() => { void refresh(); }, REFRESH_MS);
|
||||
const ready = refresh();
|
||||
return { ready, refresh, library, stop() {
|
||||
stopped = true;
|
||||
(dependencies.clearIntervalImpl ?? clearInterval)(interval);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
for (const region of regions) region.controller?.abort();
|
||||
library?.stop();
|
||||
} };
|
||||
}
|
||||
|
||||
export const boot = initPeerTubeFeeds;
|
||||
if (typeof globalThis.document !== 'undefined') {
|
||||
if (globalThis.document.readyState === 'loading') globalThis.document.addEventListener('DOMContentLoaded', () => boot(globalThis.document), { once: true });
|
||||
else boot(globalThis.document);
|
||||
}
|
||||
Reference in New Issue
Block a user