This commit is contained in:
Tomas Dvorak
2025-10-28 22:38:27 +01:00
parent 3d621e2187
commit 823fabee02
106 changed files with 9011 additions and 3930 deletions
+10 -1
View File
@@ -178,7 +178,16 @@ export async function fetchLogoAsBlob(logoUrl: string): Promise<Blob | null> {
fullUrl = `${apiOrigin}${logoUrl}`;
}
const response = await fetch(fullUrl);
const apiOrigin = new URL(API_URL).origin;
let fetchUrl = fullUrl;
try {
const u = new URL(fullUrl);
if (u.origin !== apiOrigin) {
fetchUrl = `${apiOrigin}/api/v1/proxy/image?url=${encodeURIComponent(fullUrl)}`;
}
} catch {}
const response = await fetch(fetchUrl);
if (!response.ok) return null;
return await response.blob();
+2
View File
@@ -101,6 +101,8 @@ export async function getArticles(params: {
featured?: boolean;
q?: string;
slug?: string;
match_id?: string | number;
month?: string; // YYYY-MM
} = {}) {
// Backend returns shape: { items, total, page, page_size }
// Normalize to { data, total, page, page_size } expected by the frontend.
+69 -12
View File
@@ -49,10 +49,14 @@ const resolveBackendUrl = (path: string) => {
// Lazy-load public overrides with lightweight cache
let overridesCache: { data: any; ts: number } | null = null;
const loadOverrides = async (): Promise<Record<string, string>> => {
type OverridesPayload = {
by_name?: Record<string, string>;
by_id?: Record<string, { name?: string; logo_url?: string }>;
};
const loadOverrides = async (): Promise<OverridesPayload> => {
const now = Date.now();
if (overridesCache && now - overridesCache.ts < 60_000) {
return (overridesCache.data?.by_name || {}) as Record<string, string>;
return (overridesCache.data || {}) as OverridesPayload;
}
try {
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
@@ -65,7 +69,7 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
// Invalidate internal FACR GET cache so consumers refetch with new logos
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
return (json || {}) as OverridesPayload;
}
} catch {}
// Fallback to cached file if API failed
@@ -79,11 +83,11 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
if (prev !== next) {
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
return (json || {}) as OverridesPayload;
}
} catch {}
overridesCache = { data: { by_name: {} }, ts: now };
return {};
return { by_name: {} };
};
// Name normalization helpers
@@ -99,14 +103,21 @@ const stripPrefixes = (s: string) => {
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) => {
const applyOverridesToClub = (club: ClubInfo, overrides: OverridesPayload) => {
if (!club?.competitions?.length) return club;
const byName = overrides?.by_name || {};
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => {
acc[norm(k)] = byName[k];
return acc;
}, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pick = (teamName?: string, original?: string) => {
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
if (teamId && byId[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url!;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const n = norm(teamName);
@@ -122,12 +133,18 @@ const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) =>
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
const pickName = (teamId?: string, original?: string) => {
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
return (v && v.trim().length > 0) ? v : original;
};
club.competitions = (club.competitions || []).map((c) => ({
...c,
matches: (c.matches || []).map((m: any) => ({
...m,
home_logo_url: pick(m.home, m.home_logo_url),
away_logo_url: pick(m.away, m.away_logo_url),
home: pickName(m.home_id, m.home),
away: pickName(m.away_id, m.away),
home_logo_url: pickLogo(m.home_id, m.home, m.home_logo_url),
away_logo_url: pickLogo(m.away_id, m.away, m.away_logo_url),
})),
}));
return club;
@@ -232,8 +249,8 @@ export const facrApi = {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}`);
// Load overrides and apply before returning/caching consumers
const byName = await loadOverrides();
const patched = applyOverridesToClub(response.data, byName);
const overrides = await loadOverrides();
const patched = applyOverridesToClub(response.data, overrides);
return patched;
} catch (error) {
return handleApiError(error);
@@ -244,7 +261,47 @@ export const facrApi = {
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}/table`);
return response.data;
const data = response.data as any;
const overrides = await loadOverrides();
const byName = overrides?.by_name || {};
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
if (teamId && byId[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url!;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const n = norm(teamName);
let candidate = exact || byNameNorm[n];
if (!candidate) {
const s = stripPrefixes(teamName);
for (const { key, url } of strippedPairs) { if (!key) continue; if (s.endsWith(key) || key.endsWith(s)) { candidate = url; break; } }
}
const chosen = candidate || original;
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
const pickName = (teamId?: string, original?: string) => {
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
return (v && v.trim().length > 0) ? v : original;
};
if (Array.isArray(data?.competitions)) {
data.competitions = data.competitions.map((c: any) => ({
...c,
table: {
overall: (c.table?.overall || []).map((r: any) => ({
...r,
team: pickName(r.team_id, r.team),
team_logo_url: pickLogo(r.team_id, r.team, r.team_logo_url),
})),
},
}));
}
return data;
} catch (error) {
return handleApiError(error);
}
+13
View File
@@ -114,7 +114,9 @@ export interface PredefinedElement {
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Layout - Rozvržení
{ name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' },
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
{ name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'brand' },
{ name: 'hero', label: 'Hlavní Sekce', description: 'Hlavní obsahová oblast s úvodním obsahem', icon: FaBullseye, category: 'layout', defaultVariant: 'grid' },
{ name: 'footer', label: 'Patička', description: 'Spodní část stránky s odkazy a kontakty', icon: FaMapSigns, category: 'layout', defaultVariant: 'standard' },
{ name: 'sidebar', label: 'Boční Panel', description: 'Boční sloupec s doplňkovým obsahem', icon: FaColumns, category: 'layout', defaultVariant: 'right' },
@@ -155,6 +157,12 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
];
export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
'style-pack': [
{ value: 'default', label: 'Výchozí', description: 'Základní sjednocený styl' },
{ value: 'modern', label: 'Moderní', description: 'Zaoblené rohy, lehké stíny, více prostoru' },
{ value: 'minimal', label: 'Minimal', description: 'Čisté, bez stínů, tenké rámečky' },
{ value: 'sparta', label: 'Sparta', description: 'Přiblížení k Sparta packu' },
],
header: [
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
{ value: 'edge', label: 'Okrajový', description: 'Moderní hlavička s gradientem' },
@@ -166,6 +174,11 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'current', label: 'Současný', description: 'Stávající navigace' },
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
],
'hero-topbar': [
{ value: 'brand', label: 'Brand', description: 'Barevná lišta s klubovými barvami a akcemi' },
{ value: 'minimal', label: 'Minimal', description: 'Průhledná/nenápadná lišta' },
{ value: 'badge', label: 'Badge', description: 'Pill styl s klubovou barvou' },
],
hero: [
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
+18 -4
View File
@@ -170,12 +170,18 @@ export const getPolls = async (params?: {
event_id?: number;
video_url?: string;
}): Promise<Poll[]> => {
const response = await api.get('/polls', { params });
const response = await api.get('/polls', {
params,
headers: { 'X-Session-Token': generateSessionToken() },
});
return response.data;
};
export const getPoll = async (id: number): Promise<PollResponse> => {
const response = await api.get(`/polls/${id}`);
const token = generateSessionToken();
const response = await api.get(`/polls/${id}`, {
headers: { 'X-Session-Token': token },
});
return response.data;
};
@@ -183,12 +189,20 @@ export const votePoll = async (
id: number,
data: PollVoteRequest
): Promise<{ message: string; poll: Poll }> => {
const response = await api.post(`/polls/${id}/vote`, data);
const token = data.session_token || generateSessionToken();
const response = await api.post(
`/polls/${id}/vote`,
{ ...data, session_token: token },
{ headers: { 'X-Session-Token': token } }
);
return response.data;
};
export const getPollResults = async (id: number): Promise<PollResultsResponse> => {
const response = await api.get(`/polls/${id}/results`);
const token = generateSessionToken();
const response = await api.get(`/polls/${id}/results`, {
headers: { 'X-Session-Token': token },
});
return response.data;
};
+28 -6
View File
@@ -22,6 +22,27 @@ export type Player = {
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[] };
function normalizePlayer(p: any): Player {
if (!p) return p as any;
const id = p.id ?? p.ID;
return {
id: typeof id === 'string' ? Number(id) : id,
first_name: p.first_name ?? p.FirstName ?? '',
last_name: p.last_name ?? p.LastName ?? '',
position: p.position ?? p.Position ?? undefined,
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
image_url: p.image_url ?? p.ImageURL ?? undefined,
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
nationality: p.nationality ?? p.Nationality ?? undefined,
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
height: p.height ?? p.Height ?? undefined,
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,
} as Player;
}
export async function getMatches() {
const res = await api.get<Match[] | { data: Match[] }>('/matches');
return Array.isArray(res.data) ? res.data : res.data.data;
@@ -33,15 +54,16 @@ export async function getStandings() {
}
export async function getPlayers() {
const res = await api.get<Player[] | { data?: Player[]; items?: Player[] }>('/players');
if (Array.isArray(res.data)) return res.data as Player[];
const d = res.data as any;
return (d?.data || d?.items || []) as Player[];
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
const raw = Array.isArray(res.data)
? res.data
: ((res.data as any).data || (res.data as any).items);
return (raw || []).map(normalizePlayer);
}
export async function getPlayer(id: number | string) {
const res = await api.get<Player>(`/players/${id}`);
return res.data;
const res = await api.get<any>(`/players/${id}`);
return normalizePlayer(res.data);
}
export async function getSponsors() {
+146 -86
View File
@@ -35,60 +35,50 @@ export interface SearchResults {
total: number;
}
// Enhanced scoring function for relevance with keyword support
// Enhanced scoring function for relevance with keyword support (accent-insensitive)
const scoreMatch = (text: string, query: string): number => {
const t = (text || '').toLowerCase();
const q = (query || '').toLowerCase();
if (!t || !q) return 0;
// Exact match - highest score
if (t === q) return 100;
// Starts with query - very high score
if (t.startsWith(q)) return 80;
// Contains query as whole substring
const idx = t.indexOf(q);
if (idx >= 0) return 60 - Math.min(idx, 30);
// Keyword matching - split query into words and check each
const keywords = q.split(/\s+/).filter(k => k.length > 1);
if (keywords.length > 1) {
let matchedKeywords = 0;
let totalScore = 0;
for (const keyword of keywords) {
if (t.includes(keyword)) {
matchedKeywords++;
const keywordIdx = t.indexOf(keyword);
// Score based on position and keyword match
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
const base = (t: string, q: string): number => {
if (!t || !q) return 0;
if (t === q) return 100;
if (t.startsWith(q)) return 80;
const idx = t.indexOf(q);
if (idx >= 0) return 60 - Math.min(idx, 30);
const keywords = q.split(/\s+/).filter(k => k.length > 1);
if (keywords.length > 1) {
let matchedKeywords = 0;
let totalScore = 0;
for (const keyword of keywords) {
if (t.includes(keyword)) {
matchedKeywords++;
const keywordIdx = t.indexOf(keyword);
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
}
}
if (matchedKeywords >= keywords.length / 2) {
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
}
}
// If at least half the keywords match, return proportional score
if (matchedKeywords >= keywords.length / 2) {
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
const chars = q.split('');
let lastIdx = -1;
let matched = 0;
for (const char of chars) {
const charIdx = t.indexOf(char, lastIdx + 1);
if (charIdx > lastIdx) {
matched++;
lastIdx = charIdx;
}
}
}
// Partial character matching for typos/fuzzy search
const chars = q.split('');
let lastIdx = -1;
let matched = 0;
for (const char of chars) {
const charIdx = t.indexOf(char, lastIdx + 1);
if (charIdx > lastIdx) {
matched++;
lastIdx = charIdx;
if (matched >= chars.length * 0.8) {
return Math.min(25, Math.floor((matched / chars.length) * 25));
}
}
if (matched >= chars.length * 0.8) {
return Math.min(25, Math.floor((matched / chars.length) * 25));
}
return 0;
return 0;
};
const strip = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const t0 = (text || '').toLowerCase();
const q0 = (query || '').toLowerCase();
const t1 = strip(t0);
const q1 = strip(q0);
return Math.max(base(t0, q0), base(t1, q1));
};
// Resolve backend URLs for assets
@@ -105,6 +95,18 @@ const resolveBackendUrl = (path: string): string => {
}
};
// Small helper to fetch JSON from backend or cache with safe failure handling
const fetchJSON = async <T>(path: string): Promise<T | null> => {
try {
const url = resolveBackendUrl(path);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
};
const normalizeName = (value: string) =>
String(value || '')
.normalize('NFD')
@@ -149,49 +151,77 @@ export async function searchAll(query: string): Promise<SearchResults> {
galleryRes,
] = await Promise.allSettled([
relatedClubsPromise,
// Clubs from FACR
facrApi.searchClubs(query).catch(() => ({ results: [] })),
// Matches (upcoming)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
const apiUrl = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
try {
const r = await fetch(apiUrl, { cache: 'no-cache' });
let arr: any = [];
if (r.ok) arr = await r.json();
if (!Array.isArray(arr) || arr.length === 0) {
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
return Array.isArray(fallback) ? fallback : [];
}
return arr;
} catch {
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
return Array.isArray(fallback) ? fallback : [];
}
})(),
// Matches (past)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
const apiUrl = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
try {
const r = await fetch(apiUrl, { cache: 'no-cache' });
let arr: any = [];
if (r.ok) arr = await r.json();
if (Array.isArray(arr) && arr.length > 0) return arr;
} catch {}
// Build from FACR club cache as fallback
const facr = await fetchJSON<any>(`/cache/prefetch/facr_club_info.json`);
if (!facr || !Array.isArray(facr?.competitions)) return [];
const now = new Date();
const out: any[] = [];
for (const c of facr.competitions) {
const compName = String(c?.name || c?.code || '').trim();
const matches = Array.isArray(c?.matches) ? c.matches : [];
for (const m of matches) {
const dt = String(m?.date_time || '').trim();
if (!dt) continue;
const [datePart, timePart = '00:00'] = dt.split(' ');
const [day, month, year] = String(datePart || '').split('.');
if (!day || !month || !year) continue;
const isoDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const timeStr = String(timePart).slice(0, 5);
const ts = new Date(`${isoDate}T${timeStr || '00:00'}:00`);
if (!(ts instanceof Date) || isNaN(ts.getTime())) continue;
// Past only
if (ts.getTime() >= now.getTime()) continue;
out.push({
id: m?.match_id || m?.matchId,
home: m?.home,
away: m?.away,
competition: compName,
date: isoDate,
time: timeStr || undefined,
venue: m?.venue,
home_logo_url: m?.home_logo_url,
away_logo_url: m?.away_logo_url,
});
}
}
return out;
})(),
// Articles
getArticles({ q: query, published: true, page: 1, page_size: 50 }),
// Players
getPlayers(),
// Events
getUpcomingEvents(),
// Sponsors
getSponsors(),
// Teams
(async () => {
const res = await api.get('/teams');
return Array.isArray(res.data) ? res.data : res.data?.data || [];
})(),
// Contacts
(async () => {
try {
const res = await api.get('/contacts');
// Backend returns { categories: {...}, uncategorized: [...] }
// Flatten into a single array
const grouped = res.data?.categories || {};
const uncategorized = res.data?.uncategorized || [];
const allContacts = [...uncategorized];
@@ -205,8 +235,6 @@ export async function searchAll(query: string): Promise<SearchResults> {
return [];
}
})(),
// Gallery albums
(async () => {
try {
const res = await api.get('/gallery/albums');
@@ -232,14 +260,9 @@ export async function searchAll(query: string): Promise<SearchResults> {
const clubsData = clubsRes.status === 'fulfilled' ? (clubsRes.value as any)?.results || [] : [];
const clubs: SearchResult[] = clubsData
.filter((c: any) => {
// Filter out clubs with no name or empty name
const name = String(c.name || '').trim();
if (!name) return false;
if (!hasRelatedFilter) return true;
const idKey = String(c.club_id || c.id || '').toLowerCase();
const nameKey = normalizeName(c.name);
return (idKey && relatedById.has(idKey)) || (nameKey && relatedByName.has(nameKey));
return true;
})
.map((c: any) => {
const idKey = String(c.club_id || c.id || '').toLowerCase();
@@ -280,7 +303,26 @@ export async function searchAll(query: string): Promise<SearchResults> {
// Process matches (upcoming)
const matchesData = matchesRes.status === 'fulfilled' ? matchesRes.value : [];
const matches: SearchResult[] = (Array.isArray(matchesData) ? matchesData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
const queryMatches = (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
// If related filter is active, allow either related match OR explicit query match
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
})
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
return (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
})
.map((m: any, idx: number) => ({
type: 'match' as const,
id: m.id || idx,
@@ -306,7 +348,25 @@ export async function searchAll(query: string): Promise<SearchResults> {
// Process matches (past)
const matchesPastData = matchesPastRes.status === 'fulfilled' ? matchesPastRes.value : [];
const matchesPast: SearchResult[] = (Array.isArray(matchesPastData) ? matchesPastData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
const queryMatches = (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
})
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
return (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
})
.map((m: any, idx: number) => ({
type: 'match_past' as const,
id: `past-${m.id || idx}`,
+40
View File
@@ -0,0 +1,40 @@
import api from './api';
export interface CreateShortLinkPayload {
target_url: string;
title?: string;
source_type?: 'article' | 'event' | 'other' | string;
source_id?: number;
expires_at?: string | null;
code?: string;
active?: boolean;
}
export interface ShortLinkResponse {
id: number;
code: string;
short_url: string;
link: any;
}
export async function createShortLink(payload: CreateShortLinkPayload): Promise<ShortLinkResponse> {
try {
// Prefer editor-accessible endpoint
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
return res.data;
} catch (e: any) {
// Fallback to admin endpoint (for admin-only contexts)
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
return res2.data;
}
}
export async function listShortLinks(): Promise<{ items: any[] }> {
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
return res.data;
}
export async function getShortLinkStats(id: number | string): Promise<any> {
const res = await api.get(`/admin/shortlinks/${id}/stats`);
return res.data;
}