This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+44
View File
@@ -0,0 +1,44 @@
import api from '../../services/api';
import { CommentItem } from '../../services/comments';
export type AdminCommentsList = {
items: CommentItem[];
total: number;
page: number;
page_size: number;
};
export async function adminListComments(params: { status?: 'visible'|'hidden'; target_type?: string; target_id?: string; user_id?: string; page?: number; page_size?: number; }): Promise<AdminCommentsList> {
const res = await api.get('/admin/comments', { params });
return res.data as AdminCommentsList;
}
export async function adminUpdateCommentStatus(id: number, status: 'visible'|'hidden'): Promise<{ ok: boolean }>{
const res = await api.patch(`/admin/comments/${id}/status`, { status });
return res.data as { ok: boolean };
}
export async function adminBanUser(user_id: number, reason: string, duration_hours?: number): Promise<{ ok: boolean }>{
const res = await api.post('/admin/comments/ban', { user_id, reason, duration_hours: duration_hours || 0 });
return res.data as { ok: boolean };
}
export type UnbanRequest = {
id: number;
user_id: number;
message: string;
status: 'pending'|'approved'|'rejected';
created_at: string;
resolved_by_id?: number | null;
resolved_at?: string | null;
};
export async function adminListUnbanRequests(): Promise<{ items: UnbanRequest[] }>{
const res = await api.get('/admin/comments/unban-requests');
return res.data as { items: UnbanRequest[] };
}
export async function adminResolveUnban(id: number, action: 'approve'|'reject'): Promise<{ ok: boolean }>{
const res = await api.post(`/admin/comments/unban-requests/${id}/resolve`, { action });
return res.data as { ok: boolean };
}
+68
View File
@@ -0,0 +1,68 @@
import api from '../api';
import { RewardItem } from '../../services/engagement';
export type AdminRewardItem = RewardItem & {
active: boolean;
stock: number;
metadata?: Record<string, any>;
created_at?: string;
updated_at?: string;
};
export type AdminRewardsResponse = { items: AdminRewardItem[] };
export async function adminListRewards(params?: { active?: boolean }): Promise<AdminRewardItem[]> {
const res = await api.get('/admin/engagement/rewards', { params });
return (res.data as AdminRewardsResponse).items || [];
}
export async function adminCreateReward(body: {
name: string;
type: string;
cost_points: number;
image_url?: string;
stock?: number;
active?: boolean;
metadata?: Record<string, any>;
}): Promise<AdminRewardItem> {
const res = await api.post('/admin/engagement/rewards', body);
return res.data as AdminRewardItem;
}
export async function adminUpdateReward(id: number, body: Partial<{
name: string;
type: string;
cost_points: number;
image_url: string;
stock: number;
active: boolean;
metadata: Record<string, any>;
}>): Promise<{ ok: boolean }>{
const res = await api.put(`/admin/engagement/rewards/${id}`, body);
return res.data as { ok: boolean };
}
export async function adminDeleteReward(id: number): Promise<{ ok: boolean }>{
const res = await api.delete(`/admin/engagement/rewards/${id}`);
return res.data as { ok: boolean };
}
export type AdminRedemption = {
id: number;
user_id: number;
reward_id: number;
status: 'pending'|'approved'|'rejected'|'fulfilled'|string;
created_at?: string;
};
export type AdminRedemptionsResponse = { items: AdminRedemption[] };
export async function adminListRedemptions(params?: { status?: string }): Promise<AdminRedemption[]> {
const res = await api.get('/admin/engagement/redemptions', { params });
return (res.data as AdminRedemptionsResponse).items || [];
}
export async function adminUpdateRedemptionStatus(id: number, action: 'approve'|'reject'|'fulfill'): Promise<{ ok: boolean; status: string }>{
const res = await api.patch(`/admin/engagement/redemptions/${id}`, { action });
return res.data as { ok: boolean; status: string };
}
+33
View File
@@ -28,6 +28,39 @@ export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGene
return parsedData;
}
// Instagram generation
export interface AIGenerateInstagramMatch {
home?: string;
away?: string;
competition?: string;
date_time?: string;
venue?: string;
score?: string;
}
export interface AIGenerateInstagramReq {
type?: 'article' | 'event' | 'generic' | string;
title?: string;
content?: string;
club_name?: string;
link: string;
hashtags?: string[];
audience?: string;
tone?: string;
match?: AIGenerateInstagramMatch | null;
}
export interface AIGenerateInstagramResp { text: string }
export async function generateInstagramAI(payload: AIGenerateInstagramReq): Promise<AIGenerateInstagramResp> {
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload);
let parsed: any = data;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { text: '' }; }
}
return parsed as AIGenerateInstagramResp;
}
export interface AIGenerateCSSReq {
prompt: string;
element_name?: string;
+37 -2
View File
@@ -35,14 +35,49 @@ export const api: AxiosInstance = axios.create({
timeout: 20000, // 20 seconds to better tolerate slower endpoints
});
// Simple in-memory CSRF token cache
let csrfTokenCache: { token: string; fetchedAt: number } | null = null;
async function getCsrfToken(): Promise<string | null> {
try {
// Refresh token every 45 minutes
const now = Date.now();
if (csrfTokenCache && now - csrfTokenCache.fetchedAt < 45 * 60 * 1000) {
return csrfTokenCache.token;
}
const res = await fetch(`${API_URL.replace(/\/$/, '')}/csrf-token`, {
credentials: 'include',
headers: { 'Accept': 'application/json' },
});
if (!res.ok) return null;
const data = await res.json();
const token = data?.csrf_token || null;
if (token) {
csrfTokenCache = { token, fetchedAt: now };
}
return token;
} catch {
return null;
}
}
// Request interceptor - attach bearer token when available
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
async (config: InternalAxiosRequestConfig) => {
const token = getToken();
config.headers = config.headers || {};
if (token) {
config.headers = config.headers || {};
(config.headers as any).Authorization = `Bearer ${token}`;
}
// For cookie-based flows (no Bearer header), attach X-CSRF-Token on mutating methods
const method = (config.method || 'get').toLowerCase();
const isMutating = method === 'post' || method === 'put' || method === 'patch' || method === 'delete';
const hasAuth = !!(config.headers as any).Authorization;
if (isMutating && !hasAuth) {
const csrf = await getCsrfToken();
if (csrf) {
(config.headers as any)['X-CSRF-Token'] = csrf;
}
}
return config;
},
(error) => {
+7 -18
View File
@@ -1,5 +1,4 @@
import axios from 'axios';
import { API_URL as API_BASE_URL } from './api';
import api from './api';
export interface ClothingItem {
id: number;
@@ -21,44 +20,34 @@ export interface ClothingResponse {
// Public endpoint - get all active clothing items
export const getClothing = async (): Promise<ClothingItem[]> => {
const response = await axios.get<ClothingResponse>(`${API_BASE_URL}/clothing`);
const response = await api.get<ClothingResponse>('/clothing');
return response.data.data;
};
// Admin endpoint - get all clothing items
export const getClothingAdmin = async (): Promise<ClothingItem[]> => {
const response = await axios.get<ClothingResponse>(`${API_BASE_URL}/admin/clothing`, {
withCredentials: true,
});
const response = await api.get<ClothingResponse>('/admin/clothing');
return response.data.data;
};
// Admin endpoint - create clothing item
export const createClothing = async (data: Partial<ClothingItem>): Promise<ClothingItem> => {
const response = await axios.post<ClothingItem>(`${API_BASE_URL}/admin/clothing`, data, {
withCredentials: true,
});
const response = await api.post<ClothingItem>('/admin/clothing', data);
return response.data;
};
// Admin endpoint - update clothing item
export const updateClothing = async (id: number, data: Partial<ClothingItem>): Promise<ClothingItem> => {
const response = await axios.put<ClothingItem>(`${API_BASE_URL}/admin/clothing/${id}`, data, {
withCredentials: true,
});
const response = await api.put<ClothingItem>(`/admin/clothing/${id}`, data);
return response.data;
};
// Admin endpoint - delete clothing item
export const deleteClothing = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/clothing/${id}`, {
withCredentials: true,
});
await api.delete(`/admin/clothing/${id}`);
};
// Admin endpoint - update display order
export const updateClothingOrder = async (items: Array<{ id: number; display_order: number }>): Promise<void> => {
await axios.post(`${API_BASE_URL}/admin/clothing/reorder`, items, {
withCredentials: true,
});
await api.post('/admin/clothing/reorder', items);
};
+72
View File
@@ -0,0 +1,72 @@
import api from './api';
export type TargetType = 'article' | 'event' | 'gallery_album' | 'youtube_video';
export type CommentItem = {
id: number;
target_type: TargetType;
target_id: string;
parent_id?: number | null;
content: string;
status?: 'visible' | 'hidden';
is_edited?: boolean;
edited_at?: string | null;
created_at: string;
updated_at: string;
reactions?: Record<string, number>;
my_reaction?: string;
user: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
};
};
export type CommentsResponse = {
items: CommentItem[];
total: number;
page: number;
page_size: number;
};
export async function listComments(params: { target_type: TargetType; target_id: string; page?: number; page_size?: number; }): Promise<CommentsResponse> {
const res = await api.get('/comments', { params });
return res.data as CommentsResponse;
}
export async function createComment(body: { target_type: TargetType; target_id: string; content: string; parent_id?: number | null; }): Promise<CommentItem> {
const res = await api.post('/comments', body);
return res.data as CommentItem;
}
export async function updateComment(id: number, body: { content: string; }): Promise<CommentItem> {
const res = await api.put(`/comments/${id}`, body);
return res.data as CommentItem;
}
export async function deleteComment(id: number): Promise<{ ok: boolean }>{
const res = await api.delete(`/comments/${id}`);
return res.data as { ok: boolean };
}
export async function reactComment(id: number, type: string): Promise<{ ok: boolean }>{
const res = await api.post(`/comments/${id}/react`, { type });
return res.data as { ok: boolean };
}
export async function unreactComment(id: number): Promise<{ ok: boolean }>{
const res = await api.delete(`/comments/${id}/react`);
return res.data as { ok: boolean };
}
export async function requestUnban(message: string): Promise<{ ok: boolean }>{
const res = await api.post('/comments/unban-request', { message });
return res.data as { ok: boolean };
}
export async function reportComment(id: number, reason?: string): Promise<{ ok: boolean }>{
const res = await api.post(`/comments/${id}/report`, { reason });
return res.data as { ok: boolean };
}
+66
View File
@@ -0,0 +1,66 @@
import api from './api';
export type EngagementProfile = {
user_id: number;
points: number;
level: number;
xp: number;
avatar_url?: string;
animated_avatar_url?: string;
achievements: number;
};
export async function getProfile(): Promise<EngagementProfile> {
const res = await api.get('/engagement/profile');
return res.data as EngagementProfile;
}
export type RewardItem = {
id: number;
name: string;
type: 'avatar_static' | 'avatar_animated' | 'merch_coupon' | 'custom' | string;
cost_points: number;
image_url?: string;
stock?: number;
active?: boolean;
metadata?: Record<string, any>;
};
export async function getRewards(): Promise<RewardItem[]> {
const res = await api.get('/engagement/rewards');
return res.data as RewardItem[];
}
export async function patchAvatar(body: { avatar_url?: string; animated_avatar_url?: string }): Promise<{ ok: boolean }>{
const res = await api.patch('/engagement/avatar', body);
return res.data as { ok: boolean };
}
export async function redeemReward(reward_id: number): Promise<{ ok: boolean; status: string }>{
const res = await api.post('/engagement/redeem', { reward_id });
return res.data as { ok: boolean; status: string };
}
export type AchievementsResponse = {
achievements: Array<{
id: number;
code: string;
title: string;
description: string;
points: number;
xp: number;
icon?: string;
achieved: boolean;
achieved_at?: string;
}>;
counters: {
comments: number;
votes: number;
newsletter: boolean;
};
};
export async function getAchievements(): Promise<AchievementsResponse> {
const res = await api.get('/engagement/achievements');
return res.data as AchievementsResponse;
}
+18
View File
@@ -45,6 +45,17 @@ export interface DuplicateFiles {
[hash: string]: FileInfo[];
}
export interface StorageUsage {
used_bytes: number;
used_count: number;
quota_mb: number;
quota_bytes: number;
percent: number;
warn_percent: number;
critical_percent: number;
status: 'ok' | 'warn' | 'critical';
}
export const getAllFiles = async (params?: {
search?: string;
mime_type?: string;
@@ -72,6 +83,13 @@ export const getDuplicateFiles = async (): Promise<DuplicateFiles> => {
return response.data;
};
export const getStorageUsage = async (): Promise<StorageUsage> => {
const response = await axios.get(`${API_URL}/admin/files/usage`, {
withCredentials: true,
});
return response.data;
};
export const getFileUsages = async (fileId: number): Promise<any[]> => {
const response = await axios.get(`${API_URL}/admin/files/${fileId}/usages`, {
withCredentials: true,
+51 -8
View File
@@ -27,31 +27,73 @@ export interface SocialLink {
icon?: string;
}
// Normalize backend objects (backend may return `ID` instead of `id`)
function normalizeNavItem(raw: any): NavigationItem {
if (!raw || typeof raw !== 'object') return raw as NavigationItem;
const id = raw.id ?? raw.ID;
const children = Array.isArray(raw.children)
? raw.children.map((c: any) => normalizeNavItem(c))
: undefined;
return {
id,
label: raw.label,
url: raw.url,
icon: raw.icon,
type: raw.type,
page_type: raw.page_type,
page_id: raw.page_id,
visible: raw.visible,
display_order: raw.display_order,
parent_id: raw.parent_id,
children,
target: raw.target,
css_class: raw.css_class,
requires_auth: raw.requires_auth,
requires_admin: raw.requires_admin,
} as NavigationItem;
}
function normalizeSocialLink(raw: any): SocialLink {
if (!raw || typeof raw !== 'object') return raw as SocialLink;
const id = raw.id ?? raw.ID;
return {
id,
platform: raw.platform,
url: raw.url,
display_order: raw.display_order,
visible: raw.visible,
icon: raw.icon,
} as SocialLink;
}
// Public endpoints
export const getNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await api.get(`/navigation`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeNavItem(it));
};
export const getSocialLinks = async (): Promise<SocialLink[]> => {
const response = await api.get(`/social-links`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeSocialLink(it));
};
// Admin endpoints
export const getAllNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await api.get(`/admin/navigation`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeNavItem(it));
};
export const createNavigationItem = async (item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await api.post(`/admin/navigation`, item);
return response.data;
return normalizeNavItem(response.data);
};
export const updateNavigationItem = async (id: number, item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await api.put(`/admin/navigation/${id}`, item);
return response.data;
return normalizeNavItem(response.data);
};
export const deleteNavigationItem = async (id: number): Promise<void> => {
@@ -65,17 +107,18 @@ export const reorderNavigationItems = async (orders: { id: number; display_order
// Social links admin endpoints
export const getAllSocialLinks = async (): Promise<SocialLink[]> => {
const response = await api.get(`/admin/social-links`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeSocialLink(it));
};
export const createSocialLink = async (link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await api.post(`/admin/social-links`, link);
return response.data;
return normalizeSocialLink(response.data);
};
export const updateSocialLink = async (id: number, link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await api.put(`/admin/social-links/${id}`, link);
return response.data;
return normalizeSocialLink(response.data);
};
export const deleteSocialLink = async (id: number): Promise<void> => {
+8 -19
View File
@@ -1,5 +1,4 @@
import axios from 'axios';
import { API_URL as API_BASE_URL } from './api';
import api from './api';
import { IconType } from 'react-icons';
import {
FaRegClipboard,
@@ -52,7 +51,7 @@ export interface PageElementConfig {
// Public endpoints
export const getPageElementConfigs = async (pageType: string): Promise<PageElementConfig[]> => {
const response = await axios.get(`${API_BASE_URL}/page-elements`, {
const response = await api.get('/page-elements', {
params: { page_type: pageType }
});
return response.data || [];
@@ -60,36 +59,26 @@ export const getPageElementConfigs = async (pageType: string): Promise<PageEleme
// Admin endpoints
export const getAllPageElementConfigs = async (): Promise<PageElementConfig[]> => {
const response = await axios.get(`${API_BASE_URL}/admin/page-elements`, {
withCredentials: true,
});
const response = await api.get('/admin/page-elements');
return response.data || [];
};
export const createOrUpdatePageElementConfig = async (config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await axios.post(`${API_BASE_URL}/admin/page-elements`, config, {
withCredentials: true,
});
const response = await api.post('/admin/page-elements', config);
return response.data;
};
export const updatePageElementConfig = async (id: number, config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await axios.put(`${API_BASE_URL}/admin/page-elements/${id}`, config, {
withCredentials: true,
});
const response = await api.put(`/admin/page-elements/${id}`, config);
return response.data;
};
export const deletePageElementConfig = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/page-elements/${id}`, {
withCredentials: true,
});
await api.delete(`/admin/page-elements/${id}`);
};
export const batchUpdatePageElementConfigs = async (configs: PageElementConfig[]): Promise<{ message: string; updated: number; created: number }> => {
const response = await axios.post(`${API_BASE_URL}/admin/page-elements/batch`, configs, {
withCredentials: true,
});
const response = await api.post('/admin/page-elements/batch', configs);
return response.data;
};
@@ -138,7 +127,7 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Media - Média
{ name: 'gallery', label: 'Galerie', description: 'Fotogalerie', icon: FaImages, category: 'media', defaultVariant: 'grid' },
{ name: 'videos', label: 'Videa', description: 'YouTube videa a sestřihy', icon: FaVideo, category: 'media', defaultVariant: 'grid' },
{ name: 'videos', label: 'Videa', description: 'YouTube videa a sestřihy', icon: FaVideo, category: 'media', defaultVariant: 'carousel' },
{ name: 'live', label: 'Live Stream', description: 'Živé přenosy zápasů', icon: FaBroadcastTower, category: 'media', defaultVariant: 'featured' },
{ name: 'podcast', label: 'Podcast', description: 'Zvukové podcasty a komentáře', icon: FaPodcast, category: 'media', defaultVariant: 'list' },
{ name: 'social', label: 'Sociální Sítě', description: 'Příspěvky ze sociálních sítí', icon: FaHashtag, category: 'media', defaultVariant: 'grid' },
+20
View File
@@ -161,6 +161,21 @@ export interface PollStats {
guest_votes: number;
}
export interface PollVote {
id: number;
poll_id: number;
option_id: number;
option_text: string;
user_id?: number;
user_email?: string;
user_first_name?: string;
user_last_name?: string;
voter_name?: string;
voter_email?: string;
session_token?: string;
created_at: string;
}
// Public API
export const getPolls = async (params?: {
@@ -242,6 +257,11 @@ export const getPollStats = async (id: number): Promise<PollStats> => {
return response.data;
};
export const getPollVotes = async (id: number): Promise<PollVote[]> => {
const response = await api.get(`/admin/polls/${id}/votes`);
return response.data.votes as PollVote[];
};
// Helper to generate a session token for guest voting
export const generateSessionToken = (): string => {
const stored = localStorage.getItem('poll_session_token');
+3 -1
View File
@@ -18,6 +18,7 @@ export type Player = {
email?: string;
phone?: string;
team_id?: number;
team?: { id?: number; name?: string };
};
export type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string; tier?: string; display_order?: number };
export type Category = { id?: number; name: string; slug?: string; url?: string; children?: Category[] };
@@ -39,7 +40,8 @@ function normalizePlayer(p: any): Player {
weight: p.weight ?? p.Weight ?? undefined,
email: p.email ?? p.Email ?? undefined,
phone: p.phone ?? p.Phone ?? undefined,
team_id: p.team_id ?? p.TeamID ?? undefined,
team_id: p.team_id ?? p.TeamID ?? (p.team?.id ?? p.Team?.ID) ?? undefined,
team: (p.team || p.Team) ? { id: (p.team?.id ?? p.Team?.ID), name: (p.team?.name ?? p.Team?.Name ?? p.Team?.name) } : undefined,
} as Player;
}
+19
View File
@@ -200,6 +200,17 @@ export async function searchAll(query: string): Promise<SearchResults> {
if (!(ts instanceof Date) || isNaN(ts.getTime())) continue;
// Past only
if (ts.getTime() >= now.getTime()) continue;
// Parse score if present (e.g., "2:1")
const scoreText = String(m?.score || '').trim();
let result_home: number | undefined;
let result_away: number | undefined;
if (scoreText) {
const mm = scoreText.match(/^(\d+)\s*:\s*(\d+)$/);
if (mm) {
result_home = parseInt(mm[1], 10);
result_away = parseInt(mm[2], 10);
}
}
out.push({
id: m?.match_id || m?.matchId,
home: m?.home,
@@ -210,6 +221,10 @@ export async function searchAll(query: string): Promise<SearchResults> {
venue: m?.venue,
home_logo_url: m?.home_logo_url,
away_logo_url: m?.away_logo_url,
// include score fields when available so UI can render them
score: scoreText || undefined,
result_home,
result_away,
});
}
}
@@ -387,6 +402,10 @@ export async function searchAll(query: string): Promise<SearchResults> {
away_logo_url: m.away_logo_url,
venue: m.venue,
external_match_id: m.match_id || m.id,
// Pass through result fields when available
result: (m.result || m.result_text || m.score) || undefined,
result_home: typeof m.result_home === 'number' ? m.result_home : undefined,
result_away: typeof m.result_away === 'number' ? m.result_away : undefined,
},
score: Math.max(
scoreMatch(m.home || '', q),
+6
View File
@@ -29,6 +29,12 @@ export async function createShortLink(payload: CreateShortLinkPayload): Promise<
}
}
// Public shortlink creation for visitors (no auth; backend validates allowed host)
export async function createPublicShortLink(payload: { target_url: string; title?: string }): Promise<ShortLinkResponse> {
const res = await api.post<ShortLinkResponse>('/shortlinks/public', payload);
return res.data;
}
export async function listShortLinks(): Promise<{ items: any[] }> {
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
return res.data;
+22 -5
View File
@@ -18,20 +18,37 @@ export type YouTubeChannelPayload = {
videos: YouTubeVideo[];
};
// Simple in-memory cache for YouTube payload (per-session)
let ytMemCache: { payload: YouTubeChannelPayload | null; fetchedAt: number } | null = null;
const YT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
export const getCachedYouTube = async (): Promise<YouTubeChannelPayload | null> => {
const now = Date.now();
if (ytMemCache && now - ytMemCache.fetchedAt < YT_CACHE_TTL_MS) {
return sortByPublishedDate(ytMemCache.payload) as YouTubeChannelPayload | null;
}
try {
const res = await api.get('/youtube/videos');
const primary = res?.data as YouTubeChannelPayload | undefined;
const hasVideos = Array.isArray(primary?.videos) && (primary!.videos.length > 0);
// If backend responded with 204 or empty payload, fall back to the static cached JSON
let out: YouTubeChannelPayload | null = null;
if (res.status === 204 || !primary || !hasVideos) {
const fallback = await fetchStaticYouTubeCache();
return sortByPublishedDate(fallback);
out = await fetchStaticYouTubeCache();
} else {
out = primary || null;
}
return sortByPublishedDate(primary) as YouTubeChannelPayload;
if (out) {
ytMemCache = { payload: out, fetchedAt: now };
}
return sortByPublishedDate(out) as YouTubeChannelPayload | null;
} catch {
// Fallback: fetch static cached JSON directly from backend /cache path to avoid stressing external API
return await fetchStaticYouTubeCache();
const out = await fetchStaticYouTubeCache();
if (out) {
ytMemCache = { payload: out, fetchedAt: now };
}
return out;
}
};
@@ -40,7 +57,7 @@ const fetchStaticYouTubeCache = async (): Promise<YouTubeChannelPayload | null>
try {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const url = `${origin}/cache/prefetch/youtube_channel.json`;
const resp = await fetch(url, { cache: 'no-cache' });
const resp = await fetch(url, { cache: 'force-cache' });
if (!resp.ok) return null;
const data = (await resp.json()) as YouTubeChannelPayload;
return sortByPublishedDate(data);