forked from ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI
Réécriture SvelteKit statique (Svelte 5 runes + TS, adapter-static)
- Données PeerTube (GADE), Castopod (KUTE) et Mastodon (BOKANTE) bakées au build via +page.server.ts (throttle + retry 429 + déduplication) - Charte OKI complète : tokens, thème sombre par défaut (clair opt-in, anti-FOUC), Archivo/Inter self-hébergées, flag-bar, sprite SVG (zéro emoji en interface, zéro Font Awesome/CDN) - i18n FR/EN par routage [[locale]], bundles JSON, hreflang, %lang% serveur - Pages : accueil, /video/[uuid] (embed, téléchargements, partage, commentaires, JSON-LD VideoObject), /categories/[id], /recherche (index JSON baké, recherche client), /direct (live + annonce multi-fuseaux), /dons, /mentions-legales, /offline + 404 kréyòl - Motion gwoka : KineticText (scrub view()), ScrollProgressBar, FlagChip, reveal syncopé, View Transitions, gate prefers-reduced-motion unique - PWA : manifest + Workbox generateSW (fallback /offline/), registerSW statique - Sécurité : CSP par page en <meta> (SHA-256 des inline via scripts/postbuild-csp.mjs) + headers globaux (_headers Cloudflare et .htaccess o2switch), redirections des anciennes URLs PHP - Doc : docs/DEPLOIEMENT-SVELTEKIT.md
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { peertube } from '$lib/config';
|
||||
import { fetchJson, markdownToHtml, sanitizeHtml } from './http';
|
||||
import type {
|
||||
DownloadOption,
|
||||
SearchEntry,
|
||||
VideoComment,
|
||||
VideoDetails,
|
||||
VideoSummary
|
||||
} from '$lib/types';
|
||||
|
||||
const BASE = peertube.url;
|
||||
|
||||
interface PtAvatar {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface PtVideo {
|
||||
uuid: string;
|
||||
name: string;
|
||||
previewPath?: string;
|
||||
duration: number;
|
||||
views: number;
|
||||
publishedAt: string;
|
||||
aspectRatio?: number;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
isLive?: boolean;
|
||||
channel?: { displayName?: string; avatars?: PtAvatar[] };
|
||||
}
|
||||
|
||||
interface PtVideoList {
|
||||
data?: PtVideo[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
async function callApi(endpoint: string, params: Record<string, string | number | boolean> = {}) {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) query.set(key, String(value));
|
||||
const url = `${BASE}/api/v1/${endpoint}${query.size ? `?${query}` : ''}`;
|
||||
return fetchJson<PtVideoList>(url);
|
||||
}
|
||||
|
||||
function formatVideos(videos: PtVideo[] = []): VideoSummary[] {
|
||||
return videos.map((video) => ({
|
||||
id: video.uuid,
|
||||
title: video.name,
|
||||
thumbnail: video.previewPath ? `${BASE}${video.previewPath}` : '/images/logo.png',
|
||||
duration: video.duration,
|
||||
channel: video.channel?.displayName ?? '',
|
||||
channelAvatar: video.channel?.avatars?.[0]?.path
|
||||
? `${BASE}${video.channel.avatars[0].path}`
|
||||
: '/images/logo.png',
|
||||
views: video.views,
|
||||
date: video.publishedAt,
|
||||
aspectRatio: video.aspectRatio ?? 16 / 9,
|
||||
description: video.description ?? '',
|
||||
tags: video.tags ?? [],
|
||||
isLive: video.isLive ?? false
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRecentVideos(count: number = peertube.counts.recent): Promise<VideoSummary[]> {
|
||||
const data = await callApi('videos', { sort: '-publishedAt', count, isLocal: true });
|
||||
return formatVideos(data?.data);
|
||||
}
|
||||
|
||||
export async function getTrendingVideos(count: number = peertube.counts.trending): Promise<VideoSummary[]> {
|
||||
const data = await callApi('videos', { sort: '-trending', count, isLocal: true });
|
||||
return formatVideos(data?.data);
|
||||
}
|
||||
|
||||
export async function getVideosByTag(tag: string, count: number): Promise<VideoSummary[]> {
|
||||
const data = await callApi('videos', { tagsOneOf: tag, count, isLocal: true });
|
||||
return formatVideos(data?.data);
|
||||
}
|
||||
|
||||
export async function getShorts(count: number = peertube.counts.shorts): Promise<VideoSummary[]> {
|
||||
const data = await callApi('videos', {
|
||||
sort: '-publishedAt',
|
||||
count: 100,
|
||||
isLocal: true
|
||||
});
|
||||
const shorts = formatVideos(data?.data).filter(
|
||||
(video) => video.duration < peertube.shortsMaxDuration && video.aspectRatio <= 1
|
||||
);
|
||||
return shorts.slice(0, count);
|
||||
}
|
||||
|
||||
export async function getIndependenceVideos(
|
||||
count: number = peertube.counts.independence
|
||||
): Promise<VideoSummary[]> {
|
||||
return getVideosByTag(peertube.tagIndependence, count);
|
||||
}
|
||||
|
||||
export async function getVideosByCategory(
|
||||
categoryId: number,
|
||||
count: number = peertube.counts.category
|
||||
): Promise<VideoSummary[]> {
|
||||
const data = await callApi('videos', {
|
||||
categoryOneOf: categoryId,
|
||||
sort: '-publishedAt',
|
||||
count,
|
||||
isLocal: true
|
||||
});
|
||||
return formatVideos(data?.data);
|
||||
}
|
||||
|
||||
export async function getLiveStream(): Promise<VideoSummary | null> {
|
||||
const data = await callApi(`video-channels/${peertube.channelHandle}/videos`, {
|
||||
count: 1,
|
||||
isLocal: true,
|
||||
isLive: true,
|
||||
sort: '-publishedAt'
|
||||
});
|
||||
const live = formatVideos(data?.data).find((video) => video.isLive);
|
||||
return live ?? null;
|
||||
}
|
||||
|
||||
export async function getAllVideos(maxCount: number = peertube.counts.searchPool): Promise<VideoSummary[]> {
|
||||
const videos: VideoSummary[] = [];
|
||||
let start = 0;
|
||||
const pageSize = 100;
|
||||
while (videos.length < maxCount) {
|
||||
const data = await callApi('videos', {
|
||||
sort: '-publishedAt',
|
||||
count: pageSize,
|
||||
start,
|
||||
isLocal: true
|
||||
});
|
||||
const page = formatVideos(data?.data);
|
||||
videos.push(...page);
|
||||
if (page.length < pageSize) break;
|
||||
start += pageSize;
|
||||
}
|
||||
return videos.slice(0, maxCount);
|
||||
}
|
||||
|
||||
export async function getSearchIndex(): Promise<SearchEntry[]> {
|
||||
const videos = await getAllVideos();
|
||||
return videos.map((video) => ({
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
description: video.description,
|
||||
tags: video.tags,
|
||||
thumbnail: video.thumbnail,
|
||||
duration: video.duration,
|
||||
date: video.date,
|
||||
channel: video.channel
|
||||
}));
|
||||
}
|
||||
|
||||
interface PtVideoDetails extends PtVideo {
|
||||
category?: { id?: number; label?: string };
|
||||
licence?: { id?: number; label?: string };
|
||||
language?: { id?: string; label?: string };
|
||||
files?: { resolution?: { id?: number; label?: string }; size?: number; fileDownloadUrl?: string }[];
|
||||
streamingPlaylists?: { playlistUrl?: string; files?: { resolution?: { id?: number }; size?: number }[] }[];
|
||||
}
|
||||
|
||||
const CREATIVE_COMMONS: Record<number, { label: string; url: string }> = {
|
||||
1: { label: 'CC-BY', url: 'https://creativecommons.org/licenses/by/4.0/' },
|
||||
2: { label: 'CC-BY-SA', url: 'https://creativecommons.org/licenses/by-sa/4.0/' },
|
||||
3: { label: 'CC-BY-ND', url: 'https://creativecommons.org/licenses/by-nd/4.0/' },
|
||||
4: { label: 'CC-BY-NC', url: 'https://creativecommons.org/licenses/by-nc/4.0/' },
|
||||
5: { label: 'CC-BY-NC-SA', url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/' },
|
||||
6: { label: 'CC-BY-NC-ND', url: 'https://creativecommons.org/licenses/by-nc-nd/4.0/' },
|
||||
7: { label: 'CC0', url: 'https://creativecommons.org/publicdomain/zero/1.0/' }
|
||||
};
|
||||
|
||||
export async function getVideoById(uuid: string): Promise<VideoDetails | null> {
|
||||
const video = await fetchJson<PtVideoDetails>(`${BASE}/api/v1/videos/${uuid}`);
|
||||
if (!video?.uuid) return null;
|
||||
|
||||
const [summary] = formatVideos([video]);
|
||||
const downloads: DownloadOption[] = [];
|
||||
|
||||
for (const file of video.files ?? []) {
|
||||
if (!file.fileDownloadUrl) continue;
|
||||
downloads.push({
|
||||
label: file.resolution?.label ?? `${file.resolution?.id ?? '?'}p`,
|
||||
url: file.fileDownloadUrl,
|
||||
size: file.size ?? 0,
|
||||
resolution: file.resolution?.id ?? 0,
|
||||
type: 'file'
|
||||
});
|
||||
}
|
||||
|
||||
for (const playlist of video.streamingPlaylists ?? []) {
|
||||
if (!playlist.playlistUrl) continue;
|
||||
const size = (playlist.files ?? []).reduce((sum, f) => sum + (f.size ?? 0), 0);
|
||||
downloads.push({
|
||||
label: 'HLS',
|
||||
url: playlist.playlistUrl,
|
||||
size,
|
||||
resolution: 0,
|
||||
type: 'hls'
|
||||
});
|
||||
}
|
||||
|
||||
const licence = video.licence?.id ? CREATIVE_COMMONS[video.licence.id] : undefined;
|
||||
|
||||
return {
|
||||
...summary,
|
||||
descriptionHtml: markdownToHtml(video.description ?? ''),
|
||||
category: video.category?.id ?? null,
|
||||
licence: licence ?? null,
|
||||
embedUrl: `${BASE}/videos/embed/${video.uuid}`,
|
||||
downloads,
|
||||
language: video.language?.label ?? null
|
||||
};
|
||||
}
|
||||
|
||||
interface PtComment {
|
||||
id: string | number;
|
||||
text?: string;
|
||||
createdAt?: string;
|
||||
url?: string;
|
||||
account?: { displayName?: string; url?: string; avatars?: PtAvatar[] };
|
||||
}
|
||||
|
||||
export async function getVideoComments(uuid: string): Promise<VideoComment[]> {
|
||||
const data = await fetchJson<{ data?: PtComment[] }>(
|
||||
`${BASE}/api/v1/videos/${uuid}/comment-threads?sort=-createdAt`
|
||||
);
|
||||
return (data?.data ?? []).map((comment) => ({
|
||||
id: String(comment.id),
|
||||
author: comment.account?.displayName ?? '',
|
||||
authorAvatar: comment.account?.avatars?.[0]?.path
|
||||
? `${BASE}${comment.account.avatars[0].path}`
|
||||
: null,
|
||||
authorUrl: comment.account?.url ?? null,
|
||||
date: comment.createdAt ?? '',
|
||||
text: sanitizeHtml(comment.text ?? '')
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user