mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
249 lines
8.6 KiB
TypeScript
249 lines
8.6 KiB
TypeScript
import api, { API_URL } from './api';
|
|
import { getToken } from '../utils/auth';
|
|
|
|
const normalizeArticle = (raw: any): Article => {
|
|
if (!raw) return raw as Article;
|
|
const id = raw.id ?? raw.ID ?? raw.article_id ?? raw.articleId;
|
|
const category = raw.category ?? raw.Category;
|
|
const author = raw.author ?? raw.Author;
|
|
|
|
// Normalize attachments: backend may send a JSON string or an array
|
|
let attachments: Array<{ name: string; url: string; mime_type?: string; size?: number }> | undefined = undefined;
|
|
const aRaw = raw.attachments ?? raw.Attachments;
|
|
try {
|
|
if (Array.isArray(aRaw)) {
|
|
attachments = aRaw.map((it: any) => {
|
|
if (typeof it === 'string') {
|
|
const name = it.split('/').pop() || 'soubor';
|
|
return { name, url: it };
|
|
}
|
|
return { name: it?.name || (String(it?.url || '').split('/').pop() || 'soubor'), url: it?.url || '', mime_type: it?.mime_type || it?.type, size: it?.size };
|
|
});
|
|
} else if (typeof aRaw === 'string' && aRaw.trim() !== '') {
|
|
const parsed = JSON.parse(aRaw);
|
|
if (Array.isArray(parsed)) {
|
|
attachments = parsed.map((it: any) => {
|
|
if (typeof it === 'string') {
|
|
const name = it.split('/').pop() || 'soubor';
|
|
return { name, url: it };
|
|
}
|
|
return { name: it?.name || (String(it?.url || '').split('/').pop() || 'soubor'), url: it?.url || '', mime_type: it?.mime_type || it?.type, size: it?.size };
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore malformed attachments
|
|
}
|
|
|
|
return {
|
|
...(raw as Article),
|
|
id,
|
|
category,
|
|
author,
|
|
...(attachments ? { attachments } : {}),
|
|
} as Article;
|
|
};
|
|
|
|
export interface Article {
|
|
id: number;
|
|
title: string;
|
|
content: string;
|
|
image_url?: string;
|
|
author?: { id: number; first_name?: string; last_name?: string; email: string };
|
|
category_id?: number;
|
|
category?: { id: number; name: string; description?: string; slug?: string; created_at?: string; updated_at?: string };
|
|
category_name?: string;
|
|
published?: boolean;
|
|
featured?: boolean;
|
|
created_at?: string;
|
|
slug?: string;
|
|
seo_title?: string;
|
|
seo_description?: string;
|
|
og_image_url?: string;
|
|
published_at?: string;
|
|
updated_at?: string;
|
|
estimated_read_minutes?: number;
|
|
read_count?: number;
|
|
view_count?: number;
|
|
read_time?: number;
|
|
gallery_album_id?: string;
|
|
gallery_album_url?: string;
|
|
gallery_photo_ids?: string[];
|
|
youtube_video_id?: string;
|
|
youtube_video_title?: string;
|
|
youtube_video_url?: string;
|
|
youtube_video_thumbnail?: string;
|
|
attachments?: Array<{ name: string; url: string; mime_type?: string; size?: number }>;
|
|
}
|
|
|
|
// --- Article ⇄ Match link ---
|
|
export interface ArticleMatchLink {
|
|
article_id: number;
|
|
external_match_id?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export async function getArticleMatchLink(articleId: number | string): Promise<ArticleMatchLink> {
|
|
const res = await api.get<ArticleMatchLink>(`/articles/${articleId}/match-link`);
|
|
return res.data;
|
|
}
|
|
|
|
export async function putArticleMatchLink(articleId: number | string, payload: { external_match_id: string; title?: string }): Promise<ArticleMatchLink> {
|
|
const res = await api.post<ArticleMatchLink>(`/articles/${articleId}/match-link`, payload);
|
|
return res.data;
|
|
}
|
|
|
|
export async function deleteArticleMatchLink(articleId: number | string): Promise<{ ok: boolean }> {
|
|
const res = await api.delete<{ ok: boolean }>(`/articles/${articleId}/match-link`);
|
|
return res.data;
|
|
}
|
|
|
|
export async function getFeaturedArticles(params: {
|
|
page?: number;
|
|
page_size?: number;
|
|
} = {}) {
|
|
const res = await api.get<any>('/articles/featured', { params });
|
|
const d = res.data || {};
|
|
const itemsRaw: any[] = Array.isArray(d.items)
|
|
? d.items
|
|
: Array.isArray(d.data)
|
|
? d.data
|
|
: [];
|
|
const items: Article[] = itemsRaw.map((item) => normalizeArticle(item));
|
|
const total = typeof d.total === 'number' ? d.total : items.length;
|
|
const page = typeof d.page === 'number' ? d.page : (params as any).page || 1;
|
|
const page_size = typeof d.page_size === 'number' ? d.page_size : (params as any).page_size || items.length;
|
|
return { data: items, total, page, page_size } as Paginated<Article>;
|
|
}
|
|
|
|
export interface Paginated<T> {
|
|
data: T[];
|
|
page: number;
|
|
page_size: number;
|
|
total: number;
|
|
}
|
|
|
|
export async function getArticles(params: {
|
|
page?: number;
|
|
page_size?: number;
|
|
category_id?: number;
|
|
published?: boolean;
|
|
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.
|
|
const res = await api.get<any>('/articles', { params });
|
|
const d = res.data || {};
|
|
const itemsRaw: any[] = Array.isArray(d.items)
|
|
? d.items
|
|
: Array.isArray(d.data)
|
|
? d.data
|
|
: [];
|
|
const items: Article[] = itemsRaw.map((item) => normalizeArticle(item));
|
|
const total = typeof d.total === 'number' ? d.total : (typeof d.count === 'number' ? d.count : items.length);
|
|
const page = typeof d.page === 'number' ? d.page : (params as any).page || 1;
|
|
const page_size = typeof d.page_size === 'number' ? d.page_size : (params as any).page_size || items.length;
|
|
return { data: items, total, page, page_size } as Paginated<Article>;
|
|
}
|
|
|
|
export async function getArticle(id: number | string) {
|
|
const res = await api.get<Article>(`/articles/${id}`);
|
|
return normalizeArticle(res.data);
|
|
}
|
|
|
|
export interface CreateArticlePayload {
|
|
title: string;
|
|
content: string;
|
|
category_id?: number;
|
|
category_name?: string; // optional: backend will resolve/create category by name
|
|
published?: boolean;
|
|
image_url?: string;
|
|
slug?: string;
|
|
seo_title?: string;
|
|
seo_description?: string;
|
|
og_image_url?: string;
|
|
featured?: boolean;
|
|
gallery_album_id?: string;
|
|
gallery_album_url?: string;
|
|
gallery_photo_ids?: string[];
|
|
youtube_video_id?: string;
|
|
youtube_video_title?: string;
|
|
youtube_video_url?: string;
|
|
youtube_video_thumbnail?: string;
|
|
attachments?: Array<{ name: string; url: string; mime_type?: string; size?: number }>;
|
|
}
|
|
|
|
export async function createArticle(payload: CreateArticlePayload) {
|
|
const res = await api.post<Article>('/articles', payload);
|
|
return normalizeArticle(res.data);
|
|
}
|
|
|
|
export type UpdateArticlePayload = Partial<CreateArticlePayload>;
|
|
|
|
export async function updateArticle(id: number | string, payload: UpdateArticlePayload) {
|
|
const res = await api.put<Article>(`/articles/${id}`, payload);
|
|
return normalizeArticle(res.data);
|
|
}
|
|
|
|
export async function deleteArticle(id: number | string) {
|
|
const res = await api.delete<{ success: boolean }>(`/articles/${id}`);
|
|
return res.data;
|
|
}
|
|
|
|
// Fetch by slug if the backend exposes /articles/slug/:slug.
|
|
// Falls back to querying the list endpoint with ?slug= if needed.
|
|
export async function getArticleBySlug(slug: string) {
|
|
try {
|
|
const res = await api.get<Article>(`/articles/slug/${encodeURIComponent(slug)}`);
|
|
return normalizeArticle(res.data);
|
|
} catch (e) {
|
|
// Fallback: attempt list query through normalized helper and return first match
|
|
const list = await getArticles({ slug });
|
|
return list.data?.[0];
|
|
}
|
|
}
|
|
|
|
export async function uploadFile(file: File) {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
// Important: Do NOT set Content-Type manually so Axios can attach the proper multipart boundary.
|
|
// The api instance will automatically add Authorization via the interceptor when a token exists.
|
|
const res = await api.post<{ url: string; name: string; type: string; size: number }>(
|
|
'/upload',
|
|
form
|
|
);
|
|
const data = res.data;
|
|
let url = data.url || '';
|
|
try {
|
|
// Normalize URLs returned by backend: if the returned URL points to the frontend origin (dev server)
|
|
// or to the API origin, convert it to a backend-relative path like '/uploads/2025/...'
|
|
const parsed = new URL(url, window.location.origin);
|
|
const appOrigin = window.location.origin;
|
|
const apiOrigin = new URL(API_URL).origin;
|
|
if (parsed.origin === appOrigin) {
|
|
// Rewrite frontend-origin URLs to API origin, then strip origin to keep a relative path
|
|
url = parsed.pathname + parsed.search + parsed.hash;
|
|
} else if (parsed.origin === apiOrigin) {
|
|
// Keep only the path so stored values are consistent (backend relative path)
|
|
url = parsed.pathname + parsed.search + parsed.hash;
|
|
}
|
|
} catch (e) {
|
|
// ignore parsing errors, keep original
|
|
}
|
|
return { ...data, url };
|
|
}
|
|
|
|
export async function trackArticleView(id: number | string) {
|
|
try {
|
|
// Send an explicit empty JSON body to satisfy backend Content-Type validation
|
|
await api.post(`/articles/${id}/track-view`, {});
|
|
} catch (e) {
|
|
console.debug('Failed to track article view:', e);
|
|
}
|
|
}
|
|
|