This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
import api, { API_URL } from './api';
import { getToken } from '../utils/auth';
const normalizeArticle = (raw: any): Article => {
if (!raw) return raw;
const id = raw.id ?? raw.ID ?? raw.article_id ?? raw.articleId;
const category = raw.category ?? raw.Category;
const author = raw.author ?? raw.Author;
return {
...(raw as Article),
id,
category,
author,
} 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;
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;
}
// --- 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;
} = {}) {
// 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;
}
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 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 {
await api.post(`/articles/${id}/track-view`);
} catch (e) {
console.debug('Failed to track article view:', e);
}
}