Files
MyClub/frontend/src/services/articles.ts
T
Tomas Dvorak f5b6f83974 dev day #99
2025-11-21 08:44:44 +01:00

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);
}
}