mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user