Major additions: onboarding tour system, correlation-id middleware, media error handler, restore script, env validation script, Dockerignore files. Updates across 70+ admin components for improved UX and error handling. Bunker Admin
190 lines
5.4 KiB
TypeScript
190 lines
5.4 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
|
|
import { PlusOutlined } from '@ant-design/icons';
|
|
import { mediaApi } from '@/lib/media-api';
|
|
import type { PlaylistSummary } from '@/types/media';
|
|
import axios from 'axios';
|
|
|
|
const { Text } = Typography;
|
|
|
|
interface BulkAddToPlaylistModalProps {
|
|
videoIds: number[];
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
export default function BulkAddToPlaylistModal({
|
|
videoIds,
|
|
open,
|
|
onClose,
|
|
onSuccess,
|
|
}: BulkAddToPlaylistModalProps) {
|
|
const [playlists, setPlaylists] = useState<PlaylistSummary[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [selectedPlaylistId, setSelectedPlaylistId] = useState<number | null>(null);
|
|
|
|
// Inline create state
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [newName, setNewName] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const fetchPlaylists = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const { data } = await mediaApi.get('/playlists/my');
|
|
setPlaylists(data.data || []);
|
|
} catch {
|
|
message.error('Failed to load playlists');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchPlaylists();
|
|
setSelectedPlaylistId(null);
|
|
setShowCreate(false);
|
|
setNewName('');
|
|
}, [open]);
|
|
|
|
const handleAdd = async () => {
|
|
if (!selectedPlaylistId || videoIds.length === 0) return;
|
|
|
|
try {
|
|
setSaving(true);
|
|
let added = 0;
|
|
let skipped = 0;
|
|
|
|
for (const mediaId of videoIds) {
|
|
try {
|
|
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
|
|
added++;
|
|
} catch (error: unknown) {
|
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
|
skipped++;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
if (added > 0) parts.push(`${added} video${added > 1 ? 's' : ''} added`);
|
|
if (skipped > 0) parts.push(`${skipped} already in playlist`);
|
|
message.success(parts.join(', '));
|
|
onSuccess?.();
|
|
} catch {
|
|
message.error('Failed to add videos to playlist');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateNew = async () => {
|
|
if (!newName.trim()) return;
|
|
|
|
try {
|
|
setCreating(true);
|
|
const { data } = await mediaApi.post('/playlists/', {
|
|
name: newName.trim(),
|
|
isPublic: false,
|
|
});
|
|
|
|
setPlaylists((prev) => [...prev, data]);
|
|
setSelectedPlaylistId(data.id);
|
|
setNewName('');
|
|
setShowCreate(false);
|
|
message.success(`Created "${data.name}"`);
|
|
} catch (error: unknown) {
|
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
|
message.error('You already have a playlist with this name');
|
|
} else {
|
|
message.error('Failed to create playlist');
|
|
}
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
|
|
|
|
return (
|
|
<Modal
|
|
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
|
|
open={open}
|
|
onOk={handleAdd}
|
|
onCancel={onClose}
|
|
confirmLoading={saving}
|
|
okText="Add"
|
|
okButtonProps={{ disabled: !selectedPlaylistId }}
|
|
>
|
|
{loading ? (
|
|
<div style={{ textAlign: 'center', padding: 32 }}>
|
|
<Spin />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Select
|
|
placeholder="Select a playlist"
|
|
value={selectedPlaylistId}
|
|
onChange={setSelectedPlaylistId}
|
|
style={{ width: '100%', marginBottom: 12 }}
|
|
options={playlists.map((p) => ({
|
|
value: p.id,
|
|
label: `${p.name} (${p.videoCount} videos)`,
|
|
}))}
|
|
showSearch
|
|
filterOption={(input, option) =>
|
|
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
|
|
}
|
|
/>
|
|
|
|
{selectedPlaylist && (
|
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
|
{selectedPlaylist.isPublic ? 'Public' : 'Private'} playlist
|
|
{selectedPlaylist.videoCount > 0 && ` with ${selectedPlaylist.videoCount} videos`}
|
|
</Text>
|
|
)}
|
|
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
|
|
{showCreate ? (
|
|
<Space.Compact style={{ width: '100%' }}>
|
|
<Input
|
|
placeholder="New playlist name"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onPressEnter={handleCreateNew}
|
|
maxLength={100}
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
onClick={handleCreateNew}
|
|
loading={creating}
|
|
disabled={!newName.trim()}
|
|
>
|
|
Create
|
|
</Button>
|
|
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
|
|
</Space.Compact>
|
|
) : (
|
|
<Button
|
|
type="dashed"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => setShowCreate(true)}
|
|
block
|
|
>
|
|
Create New Playlist
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|