feat(ui): connect admin views to real backend, add AnnouncementBanner, MSW handlers

This commit is contained in:
senke 2026-02-25 20:00:43 +01:00
parent c782bcb5b3
commit 0fc3690c18
9 changed files with 658 additions and 87 deletions

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { AdminModerationView } from './AdminModerationView';
import { ToastProvider } from '../../components/feedback/ToastProvider';
@ -55,12 +56,40 @@ export const Queue: Story = {
},
};
/**
* État de chargement.
*/
export const Loading: Story = {
name: 'Chargement',
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/reports', async () => {
await new Promise(() => {});
return HttpResponse.json({ reports: [], pagination: { total: 0, limit: 20, offset: 0 } });
}),
],
},
docs: {
description: {
story: 'Skeleton pendant le chargement des rapports.',
},
},
},
};
/**
* État vide - tous les rapports traités.
*/
export const Empty: Story = {
name: 'Queue vide',
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/reports', () =>
HttpResponse.json({ reports: [], pagination: { total: 0, limit: 20, offset: 0 } })),
],
},
docs: {
description: {
story: 'Message affiché quand tous les signalements ont été traités.',
@ -68,3 +97,23 @@ export const Empty: Story = {
},
},
};
/**
* État d'erreur.
*/
export const Error: Story = {
name: 'Erreur',
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/reports', () =>
HttpResponse.json({ error: 'Failed to list reports' }, { status: 500 })),
],
},
docs: {
description: {
story: 'Affichage en cas d\'échec du chargement.',
},
},
},
};

View file

@ -54,15 +54,9 @@ export const AdminModerationView: React.FC = () => {
try {
await adminService.resolveReport(id, action);
addToast(`Report ${action}`, 'success');
setQueue(
queue.map((r) =>
r.id === id
? {
...r,
status: (action === 'dismissed' ? 'dismissed' : 'resolved') as Report['status'],
}
: r,
),
const newStatus = (action === 'dismissed' ? 'dismissed' : 'resolved') as Report['status'];
setQueue((prev) =>
prev.map((r) => (r.id === id ? { ...r, status: newStatus } : r)),
);
} catch (e) {
addToast('Action failed', 'error');

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { AdminSettingsView } from './AdminSettingsView';
/**
@ -39,14 +40,74 @@ export const Default: Story = {
};
/**
* État de sauvegarde en cours.
* État de chargement.
*/
export const Saving: Story = {
name: 'Sauvegarde',
export const Loading: Story = {
name: 'Chargement',
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/maintenance', async () => {
await new Promise(() => {});
return HttpResponse.json({ maintenance_mode: false });
}),
http.get('*/api/v1/admin/feature-flags', async () => {
await new Promise(() => {});
return HttpResponse.json({ feature_flags: [] });
}),
http.get('*/api/v1/admin/announcements', async () => {
await new Promise(() => {});
return HttpResponse.json({ announcements: [] });
}),
],
},
docs: {
description: {
story: 'Feedback visuel pendant la sauvegarde des paramètres.',
story: 'Skeleton pendant le chargement des paramètres.',
},
},
},
};
/**
* État d'erreur.
*/
export const Error: Story = {
name: 'Erreur',
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/maintenance', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })),
http.get('*/api/v1/admin/feature-flags', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })),
http.get('*/api/v1/admin/announcements', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })),
],
},
docs: {
description: {
story: 'Affichage en cas d\'échec du chargement.',
},
},
},
};
/**
* Annonces vides.
*/
export const EmptyAnnouncements: Story = {
name: 'Sans annonces',
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/announcements', () =>
HttpResponse.json({ announcements: [] })),
],
},
docs: {
description: {
story: 'Aucune annonce configurée.',
},
},
},

View file

@ -1,14 +1,110 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Save, AlertTriangle, Server, Activity } from 'lucide-react';
import { Save, AlertTriangle, Server, Activity, Loader2 } from 'lucide-react';
import { useToast } from '../../components/feedback/ToastProvider';
import { adminService } from '../../services/adminService';
import { logger } from '@/utils/logger';
interface FeatureFlag {
name: string;
enabled: boolean;
description?: string;
}
interface Announcement {
id: string;
title: string;
content: string;
type: string;
is_active: boolean;
}
export const AdminSettingsView: React.FC = () => {
const { addToast } = useToast();
const [maintenance, setMaintenance] = useState(false);
const [uploadLimit, setUploadLimit] = useState(500); // MB
const [announcement, setAnnouncement] = useState('');
const [maintenanceLoading, setMaintenanceLoading] = useState(true);
const [uploadLimit, setUploadLimit] = useState(500);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [announcementsLoading, setAnnouncementsLoading] = useState(true);
const [featureFlags, setFeatureFlags] = useState<FeatureFlag[]>([]);
const [flagsLoading, setFlagsLoading] = useState(true);
const [newAnnouncementTitle, setNewAnnouncementTitle] = useState('');
const [newAnnouncementContent, setNewAnnouncementContent] = useState('');
useEffect(() => {
const load = async () => {
try {
const [mm, flags, ann] = await Promise.all([
adminService.getMaintenanceMode(),
adminService.getFeatureFlags(),
adminService.getAnnouncements(),
]);
setMaintenance(mm);
setFeatureFlags(flags);
setAnnouncements(ann);
} catch (e) {
logger.error('Admin settings load failed', { error: e });
addToast('Failed to load settings', 'error');
} finally {
setMaintenanceLoading(false);
setFlagsLoading(false);
setAnnouncementsLoading(false);
}
};
load();
}, []);
const handleMaintenanceToggle = async () => {
const next = !maintenance;
try {
await adminService.setMaintenanceMode(next);
setMaintenance(next);
addToast(next ? 'Maintenance mode enabled' : 'Maintenance mode disabled', 'success');
} catch {
addToast('Failed to update maintenance mode', 'error');
}
};
const handleFlagToggle = async (name: string, enabled: boolean) => {
try {
await adminService.toggleFeatureFlag(name, enabled);
setFeatureFlags((prev) => prev.map((f) => (f.name === name ? { ...f, enabled } : f)));
addToast(`Feature ${name} ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch {
addToast('Failed to toggle feature flag', 'error');
}
};
const handleCreateAnnouncement = async () => {
if (!newAnnouncementTitle.trim() || !newAnnouncementContent.trim()) {
addToast('Title and content required', 'error');
return;
}
try {
await adminService.createAnnouncement({
title: newAnnouncementTitle.trim(),
content: newAnnouncementContent.trim(),
});
const ann = await adminService.getAnnouncements();
setAnnouncements(ann);
setNewAnnouncementTitle('');
setNewAnnouncementContent('');
addToast('Announcement created', 'success');
} catch {
addToast('Failed to create announcement', 'error');
}
};
const handleDeleteAnnouncement = async (id: string) => {
try {
await adminService.deleteAnnouncement(id);
setAnnouncements((prev) => prev.filter((a) => a.id !== id));
addToast('Announcement deleted', 'success');
} catch {
addToast('Failed to delete announcement', 'error');
}
};
const handleSave = () => {
addToast('System settings updated', 'success');
@ -73,24 +169,37 @@ export const AdminSettingsView: React.FC = () => {
<h3 className="font-bold text-foreground mb-6 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" /> Feature Flags
</h3>
<div className="space-y-4">
{[
'Live Streaming',
'Marketplace Transactions',
'AI Mastering',
'Public Registrations',
].map((feature) => (
<div
key={feature}
className="flex items-center justify-between p-4 bg-muted/50 rounded border border-border"
>
<span className="font-bold text-foreground">{feature}</span>
<div className="w-10 h-5 bg-success rounded-full relative cursor-pointer">
<div className="absolute top-0.5 right-0.5 w-4 h-4 bg-background rounded-full shadow-md"></div>
{flagsLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> Loading...
</div>
) : (
<div className="space-y-4">
{featureFlags.map((flag) => (
<div
key={flag.name}
className="flex items-center justify-between p-4 bg-muted/50 rounded border border-border"
>
<div>
<span className="font-bold text-foreground">{flag.name}</span>
{flag.description && (
<p className="text-xs text-muted-foreground mt-1">{flag.description}</p>
)}
</div>
<button
type="button"
onClick={() => handleFlagToggle(flag.name, !flag.enabled)}
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${flag.enabled ? 'bg-success' : 'bg-muted'}`}
aria-label={`Toggle ${flag.name}`}
>
<div
className={`absolute top-1 w-4 h-4 bg-background rounded-full transition-all shadow-md ${flag.enabled ? 'left-7' : 'left-1'}`}
/>
</button>
</div>
</div>
))}
</div>
))}
</div>
)}
</Card>
{/* Maintenance */}
@ -108,27 +217,74 @@ export const AdminSettingsView: React.FC = () => {
Disable access for non-admin users
</div>
</div>
<div
onClick={() => setMaintenance(!maintenance)}
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${maintenance ? 'bg-destructive' : 'bg-muted'}`}
>
<div
className={`absolute top-1 w-4 h-4 bg-background rounded-full transition-all ${maintenance ? 'left-7' : 'left-1'}`}
></div>
</div>
{maintenanceLoading ? (
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
) : (
<button
type="button"
onClick={handleMaintenanceToggle}
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${maintenance ? 'bg-destructive' : 'bg-muted'}`}
aria-label="Toggle maintenance mode"
>
<div
className={`absolute top-1 w-4 h-4 bg-background rounded-full transition-all ${maintenance ? 'left-7' : 'left-1'}`}
/>
</button>
)}
</div>
<div>
<label className="block text-sm font-bold text-muted-foreground mb-2">
Global Announcement
New Global Announcement
</label>
<textarea
className="w-full bg-card border border-border rounded p-4 text-foreground outline-none focus:border-primary h-24 resize-none"
placeholder="Message to display on all pages..."
value={announcement}
onChange={(e) => setAnnouncement(e.target.value)}
<input
type="text"
className="w-full bg-card border border-border rounded p-2 text-foreground outline-none focus:border-primary mb-2"
placeholder="Title..."
value={newAnnouncementTitle}
onChange={(e) => setNewAnnouncementTitle(e.target.value)}
/>
<textarea
className="w-full bg-card border border-border rounded p-4 text-foreground outline-none focus:border-primary h-24 resize-none mb-2"
placeholder="Content..."
value={newAnnouncementContent}
onChange={(e) => setNewAnnouncementContent(e.target.value)}
/>
<Button variant="secondary" size="sm" onClick={handleCreateAnnouncement}>
Create Announcement
</Button>
</div>
{announcementsLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> Loading announcements...
</div>
) : announcements.length > 0 ? (
<div className="space-y-2">
<label className="block text-sm font-bold text-muted-foreground">
Active Announcements
</label>
{announcements.map((a) => (
<div
key={a.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded border border-border"
>
<div>
<span className="font-bold text-foreground">{a.title}</span>
<p className="text-sm text-muted-foreground truncate">{a.content}</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDeleteAnnouncement(a.id)}
>
Delete
</Button>
</div>
))}
</div>
) : null}
</div>
</Card>
</div>

View file

@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { AnnouncementBanner } from './AnnouncementBanner';
const meta: Meta<typeof AnnouncementBanner> = {
title: 'Components/Feedback/AnnouncementBanner',
component: AnnouncementBanner,
parameters: {
layout: 'padded',
docs: {
description: {
component: 'Banner global affichant les annonces actives (maintenance, info, etc.).',
},
},
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Annonces actives affichées.
*/
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('*/api/v1/announcements/active', () =>
HttpResponse.json({
announcements: [
{ id: 'a1', title: 'Maintenance planifiée', content: 'Le 28 février, maintenance de 2h.', type: 'info' },
{ id: 'a2', title: 'Nouvelle fonctionnalité', content: 'Le partage de playlists est disponible.', type: 'info' },
],
})),
],
},
},
};
/**
* Aucune annonce.
*/
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get('*/api/v1/announcements/active', () =>
HttpResponse.json({ announcements: [] })),
],
},
},
};
/**
* Annonce de type warning.
*/
export const Warning: Story = {
parameters: {
msw: {
handlers: [
http.get('*/api/v1/announcements/active', () =>
HttpResponse.json({
announcements: [
{ id: 'a1', title: 'Attention', content: 'Problèmes de latence signalés.', type: 'warning' },
],
})),
],
},
},
};

View file

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { X, Info, AlertTriangle, AlertCircle } from 'lucide-react';
import { adminService } from '@/services/adminService';
import { cn } from '@/lib/utils';
interface Announcement {
id: string;
title: string;
content: string;
type: string;
}
const typeConfig: Record<string, { icon: React.ElementType; className: string }> = {
info: { icon: Info, className: 'bg-primary/20 border-primary/50 text-foreground' },
warning: { icon: AlertTriangle, className: 'bg-amber-500/20 border-amber-500/50 text-foreground' },
error: { icon: AlertCircle, className: 'bg-destructive/20 border-destructive/50 text-foreground' },
};
export function AnnouncementBanner() {
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
useEffect(() => {
adminService.getActiveAnnouncements().then(setAnnouncements).catch(() => {});
}, []);
const visible = announcements.filter((a) => !dismissed.has(a.id));
if (visible.length === 0) return null;
return (
<div className="space-y-2 px-4 pt-2">
{visible.map((a) => {
const config = typeConfig[a.type] ?? typeConfig.info;
const Icon = config.icon;
return (
<div
key={a.id}
className={cn(
'flex items-start gap-3 rounded-lg border p-4',
config.className,
)}
role="alert"
>
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-bold">{a.title}</div>
<p className="text-sm opacity-90">{a.content}</p>
</div>
<button
type="button"
onClick={() => setDismissed((prev) => new Set(prev).add(a.id))}
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
aria-label="Dismiss announcement"
>
<X className="h-4 w-4" />
</button>
</div>
);
})}
</div>
);
}

View file

@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { AnnouncementBanner } from '../feedback/AnnouncementBanner';
import { GlobalPlayer } from '@/features/player/components/GlobalPlayer';
import { useQueueSync } from '@/features/player/hooks/useQueueSync';
import { useUIStore } from '@/stores/ui';
@ -50,6 +51,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
data-scroll-container="main"
>
<div className="max-w-layout-content mx-auto w-full">
<AnnouncementBanner />
{children}
</div>
</main>

View file

@ -242,4 +242,142 @@ export const handlersAdmin = [
},
});
}),
// v0.803: Moderation reports
http.get('*/api/v1/admin/reports', ({ request }) => {
const url = new URL(request.url);
const status = url.searchParams.get('status') ?? 'all';
const reports = [
{
id: 'r1',
reporter_id: 'user-1',
reported_user_id: 'user-3',
content_type: 'user',
content_id: null,
reason: 'Spam',
status: 'pending',
created_at: '2024-01-15T10:30:00Z',
},
{
id: 'r2',
reporter_id: 'user-2',
reported_user_id: null,
content_type: 'track',
content_id: 'track-105',
reason: 'Copyright',
status: 'pending',
created_at: '2024-01-15T09:15:00Z',
},
{
id: 'r3',
reporter_id: 'user-1',
reported_user_id: null,
content_type: 'comment',
content_id: 'comment-88',
reason: 'Hate Speech',
status: 'resolved',
created_at: '2024-01-14T16:20:00Z',
},
].filter((r) => status === 'all' || r.status === status);
return HttpResponse.json({ reports, pagination: { total: reports.length, limit: 20, offset: 0 } });
}),
http.post('*/api/v1/admin/reports/:id/resolve', () => {
return HttpResponse.json({ message: 'Report resolved' });
}),
http.post('*/api/v1/reports', async ({ request }) => {
const body = (await request.json()) as { content_type: string; reason: string };
return HttpResponse.json(
{
id: 'r-new',
reporter_id: 'user-1',
content_type: body.content_type ?? 'user',
reason: body.reason ?? '',
status: 'pending',
created_at: new Date().toISOString(),
},
{ status: 201 },
);
}),
// v0.803: Feature flags
http.get('*/api/v1/admin/feature-flags', () => {
return HttpResponse.json({
feature_flags: [
{ name: 'HLS_STREAMING', enabled: true, description: 'Enable HLS streaming' },
{ name: 'ROLE_MANAGEMENT', enabled: true, description: 'Enable role management' },
{ name: 'PLAYLIST_SHARE', enabled: true, description: 'Enable playlist sharing' },
{ name: 'PLAYLIST_RECOMMENDATIONS', enabled: true, description: 'Enable playlist recommendations' },
],
});
}),
http.put('*/api/v1/admin/feature-flags/:name', async ({ params, request }) => {
const body = (await request.json()) as { enabled: boolean };
return HttpResponse.json({
name: params.name,
enabled: body.enabled ?? true,
description: 'Feature flag',
updated_at: new Date().toISOString(),
});
}),
// v0.803: Maintenance mode
http.get('*/api/v1/admin/maintenance', () => {
return HttpResponse.json({ maintenance_mode: false });
}),
http.put('*/api/v1/admin/maintenance', async ({ request }) => {
const body = (await request.json()) as { enabled: boolean };
return HttpResponse.json({ maintenance_mode: body.enabled ?? false });
}),
// v0.803: Announcements
http.get('*/api/v1/admin/announcements', () => {
return HttpResponse.json({
announcements: [
{
id: 'ann-1',
title: 'Scheduled maintenance',
content: 'We will perform maintenance on Feb 28.',
type: 'info',
is_active: true,
created_at: new Date().toISOString(),
},
],
});
}),
http.post('*/api/v1/admin/announcements', async ({ request }) => {
const body = (await request.json()) as { title: string; content: string; type?: string };
return HttpResponse.json(
{
id: 'ann-new',
title: body.title ?? '',
content: body.content ?? '',
type: body.type ?? 'info',
is_active: true,
created_at: new Date().toISOString(),
},
{ status: 201 },
);
}),
http.delete('*/api/v1/admin/announcements/:id', () => {
return HttpResponse.json({ message: 'Announcement deleted' });
}),
http.get('*/api/v1/announcements/active', () => {
return HttpResponse.json({
announcements: [
{
id: 'ann-1',
title: 'Scheduled maintenance',
content: 'We will perform maintenance on Feb 28. Expect 2h downtime.',
type: 'info',
},
],
});
}),
];

View file

@ -2,41 +2,31 @@ import { apiClient } from '@/services/api/client';
import { logger } from '@/utils/logger';
import { Report } from '../types';
const MOCK_REPORTS: Report[] = [
{
id: 'r1',
targetId: 'u3',
targetType: 'user',
targetName: 'Bot_User_99',
reason: 'Spam',
description: 'Posting same link in 50 channels.',
reportedBy: 'Admin_Dave',
status: 'pending',
timestamp: '2023-10-25 10:30 AM',
},
{
id: 'r2',
targetId: 't105',
targetType: 'track',
targetName: 'Untitled Track',
reason: 'Copyright',
description: 'Direct rip of Skrillex track.',
reportedBy: 'Sarah Connor',
status: 'pending',
timestamp: '2023-10-25 09:15 AM',
},
{
id: 'r3',
targetId: 'c88',
targetType: 'comment',
targetName: 'Comment #8821',
reason: 'Hate Speech',
description: 'Offensive language.',
reportedBy: 'Cyber_Producer',
status: 'reviewed',
timestamp: '2023-10-24 04:20 PM',
},
];
/** Map backend report to frontend Report type */
function mapBackendReport(r: {
id: string;
reporter_id: string;
reported_user_id?: string | null;
content_type: string;
content_id?: string | null;
reason: string;
status: string;
created_at: string;
}): Report {
const targetId = r.content_id ?? r.reported_user_id ?? r.id;
const targetType = (r.content_type === 'user' ? 'user' : r.content_type === 'track' ? 'track' : r.content_type === 'comment' ? 'comment' : 'group') as Report['targetType'];
return {
id: r.id,
targetId: String(targetId),
targetType,
targetName: `${r.content_type} ${targetId}`.slice(0, 50),
reason: r.reason,
description: r.reason,
reportedBy: r.reporter_id,
status: (r.status === 'dismissed' ? 'dismissed' : r.status === 'resolved' ? 'resolved' : 'pending') as Report['status'],
timestamp: new Date(r.created_at).toLocaleString(),
};
}
const MOCK_UPLOADS = [
{
@ -98,15 +88,63 @@ export const adminService = {
},
getModerationQueue: async (status: string = 'pending') => {
await new Promise((resolve) => setTimeout(resolve, 600));
return MOCK_REPORTS.filter((r) => status === 'all' || r.status === status);
const response = await apiClient.get<{ reports: unknown[] }>('/admin/reports', {
params: { status: status === 'all' ? 'all' : status, limit: 100, offset: 0 },
});
const reports = (response.data?.reports ?? []) as Parameters<typeof mapBackendReport>[0][];
return reports.map(mapBackendReport);
},
resolveReport: async (_id: string, _action: string) => {
await new Promise((resolve) => setTimeout(resolve, 400));
resolveReport: async (id: string, action: string) => {
const normalizedAction = action === 'dismissed' ? 'dismiss' : action === 'banned' ? 'ban' : action === 'resolved' ? 'resolve' : action;
await apiClient.post(`/admin/reports/${id}/resolve`, { action: normalizedAction });
return { success: true };
},
getFeatureFlags: async () => {
const response = await apiClient.get<{ feature_flags: { name: string; enabled: boolean; description?: string }[] }>('/admin/feature-flags');
return response.data?.feature_flags ?? [];
},
toggleFeatureFlag: async (name: string, enabled: boolean) => {
await apiClient.put(`/admin/feature-flags/${encodeURIComponent(name)}`, { enabled });
return { success: true };
},
getMaintenanceMode: async () => {
const response = await apiClient.get<{ maintenance_mode: boolean }>('/admin/maintenance');
return response.data?.maintenance_mode ?? false;
},
setMaintenanceMode: async (enabled: boolean) => {
await apiClient.put('/admin/maintenance', { enabled });
return { success: true };
},
getAnnouncements: async () => {
const response = await apiClient.get<{ announcements: { id: string; title: string; content: string; type: string; is_active: boolean }[] }>('/admin/announcements');
return response.data?.announcements ?? [];
},
createAnnouncement: async (data: { title: string; content: string; type?: string }) => {
const response = await apiClient.post<{ id: string }>('/admin/announcements', {
title: data.title,
content: data.content,
type: data.type ?? 'info',
});
return response.data;
},
deleteAnnouncement: async (id: string) => {
await apiClient.delete(`/admin/announcements/${id}`);
return { success: true };
},
getActiveAnnouncements: async () => {
const response = await apiClient.get<{ announcements: { id: string; title: string; content: string; type: string }[] }>('/announcements/active');
return response.data?.announcements ?? [];
},
getRecentUploads: async () => {
await new Promise((resolve) => setTimeout(resolve, 400));
return MOCK_UPLOADS;