veza/apps/web/src/components/admin/AdminSettingsView.tsx

293 lines
11 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
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 [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');
};
return (
<div className="space-y-8 animate-fadeIn max-w-4xl pb-20">
<div className="flex justify-between items-center border-b border-border/50 pb-6">
<div>
<h2 className="text-2xl font-heading font-bold text-foreground tracking-tight">
SYSTEM SETTINGS
</h2>
<p className="text-muted-foreground font-mono text-sm">
Global configuration and feature management.
</p>
</div>
<Button
variant="primary"
icon={<Save className="w-4 h-4" />}
onClick={handleSave}
>
SAVE CHANGES
</Button>
</div>
{/* General Config */}
<Card variant="default">
<h3 className="font-bold text-foreground mb-6 flex items-center gap-2">
<Server className="w-5 h-5 text-muted-foreground" /> General Configuration
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-bold text-muted-foreground mb-2">
Upload Limit (MB)
</label>
<input
type="number"
className="w-full bg-card border border-border rounded p-2 text-foreground outline-none focus:border-primary"
value={uploadLimit}
onChange={(e) => setUploadLimit(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground mt-1">
Maximum file size for standard users.
</p>
</div>
<div>
<label className="block text-sm font-bold text-muted-foreground mb-2">
Default Storage Region
</label>
<select className="w-full bg-card border border-border rounded p-2 text-foreground outline-none focus:border-primary">
<option>us-east-1 (N. Virginia)</option>
<option>eu-west-1 (Ireland)</option>
<option>ap-northeast-1 (Tokyo)</option>
</select>
</div>
</div>
</Card>
{/* Feature Flags */}
<Card variant="default">
<h3 className="font-bold text-foreground mb-6 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" /> Feature Flags
</h3>
{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}
fix: stabilize builds, tests, and lint across all stacks Complete stabilization pass bringing all 3 stacks to green: Frontend (apps/web/): - Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks - Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified) - Rename 306 story imports from @storybook/react to @storybook/react-vite - Fix conditional hook call in useMediaQuery.ts useIsTablet - Move useQuery to top of LoginPage.tsx component - Remove useless try/catch in GearFormModal.tsx - Fix stale closure in ResetPasswordPage.tsx handleChange - Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio) no-ops since global StorybookDecorator already provides these — prevents nested Router / duplicate provider crashes in vitest-browser - Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile) - Update i18n initialization in test setup (await init before changeLanguage) - Update ~30 test assertions from English to French to match i18n translations - Update test assertions to match SUMI V3 design changes (shadow vs border) - Fix remaining story type errors (PlayerError, PlaylistBatchActions, TrackFilters, VirtualizedChatMessages) Backend (veza-backend-api/): - Fix response_test.go RespondWithAppError signature (2 args, not 3) - Fix TestErrorContractAuthEndpoints expected error codes (ErrCodeUnauthorized vs ErrCodeInvalidCredentials) - Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup - Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold (needs 5 unique users, not 1) - Replace NOW() PostgreSQL function with time.Now() parameter in marketplace service for SQLite test compatibility - Add missing AutoMigrate entries in marketplace_test.go (ProductImage, ProductPreview, ProductLicense, ProductReview) Results: - Frontend TypeCheck: 617 errors -> 0 errors - Frontend ESLint: 349 errors -> 0 errors - Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing) - Backend go vet: 1 error -> 0 errors - Backend tests: 5 failing -> all 13 packages passing - Rust: 150/150 tests passing (unchanged) - Storybook audit: 0 errors across 1244 stories Triage report: docs/TRIAGE_REPORT.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
className="flex items-center justify-between p-4 bg-muted/50 rounded shadow-[0_0_8px_rgba(26,26,30,0.05)]"
>
<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>
)}
</Card>
{/* Maintenance */}
<Card variant="default" className="border-destructive/30">
<h3 className="font-bold text-foreground mb-6 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" /> Emergency &
Maintenance
</h3>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="text-foreground font-bold">Maintenance Mode</div>
<div className="text-xs text-muted-foreground">
Disable access for non-admin users
</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">
New Global Announcement
</label>
<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}
fix: stabilize builds, tests, and lint across all stacks Complete stabilization pass bringing all 3 stacks to green: Frontend (apps/web/): - Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks - Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified) - Rename 306 story imports from @storybook/react to @storybook/react-vite - Fix conditional hook call in useMediaQuery.ts useIsTablet - Move useQuery to top of LoginPage.tsx component - Remove useless try/catch in GearFormModal.tsx - Fix stale closure in ResetPasswordPage.tsx handleChange - Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio) no-ops since global StorybookDecorator already provides these — prevents nested Router / duplicate provider crashes in vitest-browser - Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile) - Update i18n initialization in test setup (await init before changeLanguage) - Update ~30 test assertions from English to French to match i18n translations - Update test assertions to match SUMI V3 design changes (shadow vs border) - Fix remaining story type errors (PlayerError, PlaylistBatchActions, TrackFilters, VirtualizedChatMessages) Backend (veza-backend-api/): - Fix response_test.go RespondWithAppError signature (2 args, not 3) - Fix TestErrorContractAuthEndpoints expected error codes (ErrCodeUnauthorized vs ErrCodeInvalidCredentials) - Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup - Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold (needs 5 unique users, not 1) - Replace NOW() PostgreSQL function with time.Now() parameter in marketplace service for SQLite test compatibility - Add missing AutoMigrate entries in marketplace_test.go (ProductImage, ProductPreview, ProductLicense, ProductReview) Results: - Frontend TypeCheck: 617 errors -> 0 errors - Frontend ESLint: 349 errors -> 0 errors - Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing) - Backend go vet: 1 error -> 0 errors - Backend tests: 5 failing -> all 13 packages passing - Rust: 150/150 tests passing (unchanged) - Storybook audit: 0 errors across 1244 stories Triage report: docs/TRIAGE_REPORT.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
className="flex items-center justify-between p-3 bg-muted/50 rounded shadow-[0_0_8px_rgba(26,26,30,0.05)]"
>
<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>
);
};