feat(ui): connect admin views to real backend, add AnnouncementBanner, MSW handlers
This commit is contained in:
parent
c782bcb5b3
commit
0fc3690c18
9 changed files with 658 additions and 87 deletions
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
62
apps/web/src/components/feedback/AnnouncementBanner.tsx
Normal file
62
apps/web/src/components/feedback/AnnouncementBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue