265 lines
8.5 KiB
TypeScript
265 lines
8.5 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme, Grid } from 'antd';
|
|
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
|
import { mediaApi } from '@/lib/media-api';
|
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
import type { PlaylistVideoItem } from '@/types/media';
|
|
import axios from 'axios';
|
|
|
|
const { Text } = Typography;
|
|
|
|
interface EditPlaylistModalProps {
|
|
playlistId: number | null;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onUpdated?: () => void;
|
|
}
|
|
|
|
function formatDuration(seconds: number | null): string {
|
|
if (!seconds) return '0:00';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
export default function EditPlaylistModal({
|
|
playlistId,
|
|
open,
|
|
onClose,
|
|
onUpdated,
|
|
}: EditPlaylistModalProps) {
|
|
const [form] = Form.useForm();
|
|
const { token } = theme.useToken();
|
|
const screens = Grid.useBreakpoint();
|
|
const isMobile = !screens.md;
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [videos, setVideos] = useState<PlaylistVideoItem[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !playlistId) return;
|
|
|
|
const fetchPlaylist = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const { data } = await mediaPublicApi.get(`/playlists/${playlistId}`);
|
|
setVideos(data.videos || []);
|
|
form.setFieldsValue({
|
|
name: data.name,
|
|
description: data.description,
|
|
isPublic: data.isPublic,
|
|
});
|
|
} catch {
|
|
message.error('Failed to load playlist');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchPlaylist();
|
|
}, [open, playlistId]);
|
|
|
|
const handleSaveDetails = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
|
|
await mediaApi.put(`/playlists/${playlistId}`, {
|
|
name: values.name,
|
|
description: values.description || undefined,
|
|
isPublic: values.isPublic,
|
|
});
|
|
|
|
message.success('Playlist updated');
|
|
onUpdated?.();
|
|
} catch (error: unknown) {
|
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
|
message.error('You already have a playlist with this name');
|
|
} else if (!(error instanceof Object && 'errorFields' in error)) {
|
|
message.error('Failed to update playlist');
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveVideo = async (mediaId: number) => {
|
|
try {
|
|
await mediaApi.delete(`/playlists/${playlistId}/videos/${mediaId}`);
|
|
setVideos((prev) => prev.filter((v) => v.mediaId !== mediaId));
|
|
message.success('Video removed');
|
|
onUpdated?.();
|
|
} catch {
|
|
message.error('Failed to remove video');
|
|
}
|
|
};
|
|
|
|
const handleMoveVideo = async (index: number, direction: 'up' | 'down') => {
|
|
const newVideos = [...videos];
|
|
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
|
if (targetIndex < 0 || targetIndex >= newVideos.length) return;
|
|
|
|
const temp = newVideos[index]!;
|
|
newVideos[index] = newVideos[targetIndex]!;
|
|
newVideos[targetIndex] = temp;
|
|
|
|
// Update positions
|
|
const reordered = newVideos.map((v, i) => ({
|
|
...v,
|
|
position: i,
|
|
}));
|
|
|
|
setVideos(reordered);
|
|
|
|
try {
|
|
await mediaApi.put(`/playlists/${playlistId}/videos/reorder`, {
|
|
items: reordered.map((v) => ({ mediaId: v.mediaId, position: v.position })),
|
|
});
|
|
} catch {
|
|
message.error('Failed to reorder');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Drawer
|
|
title="Edit Playlist"
|
|
open={open}
|
|
onClose={() => {
|
|
form.resetFields();
|
|
onClose();
|
|
}}
|
|
placement="right"
|
|
width={isMobile ? '100%' : 520}
|
|
mask={false}
|
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
|
loading={loading}
|
|
>
|
|
<Tabs
|
|
items={[
|
|
{
|
|
key: 'details',
|
|
label: 'Details',
|
|
children: (
|
|
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
|
|
<Form.Item
|
|
name="name"
|
|
label="Name"
|
|
rules={[
|
|
{ required: true, message: 'Please enter a playlist name' },
|
|
{ max: 100, message: 'Name must be 100 characters or less' },
|
|
]}
|
|
>
|
|
<Input maxLength={100} />
|
|
</Form.Item>
|
|
|
|
<Form.Item name="description" label="Description">
|
|
<Input.TextArea rows={3} maxLength={500} />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="isPublic"
|
|
label="Public"
|
|
valuePropName="checked"
|
|
>
|
|
<Switch checkedChildren="Public" unCheckedChildren="Private" />
|
|
</Form.Item>
|
|
|
|
<Button type="primary" onClick={handleSaveDetails} loading={saving}>
|
|
Save Changes
|
|
</Button>
|
|
</Form>
|
|
),
|
|
},
|
|
{
|
|
key: 'videos',
|
|
label: `Videos (${videos.length})`,
|
|
children: (
|
|
<List
|
|
dataSource={videos}
|
|
locale={{ emptyText: 'No videos in this playlist' }}
|
|
renderItem={(item, index) => {
|
|
const title = item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
|
|
return (
|
|
<List.Item
|
|
style={{ padding: '8px 0' }}
|
|
actions={[
|
|
<Button
|
|
key="up"
|
|
type="text"
|
|
size="small"
|
|
icon={<ArrowUpOutlined />}
|
|
disabled={index === 0}
|
|
onClick={() => handleMoveVideo(index, 'up')}
|
|
/>,
|
|
<Button
|
|
key="down"
|
|
type="text"
|
|
size="small"
|
|
icon={<ArrowDownOutlined />}
|
|
disabled={index === videos.length - 1}
|
|
onClick={() => handleMoveVideo(index, 'down')}
|
|
/>,
|
|
<Button
|
|
key="remove"
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
onClick={() => handleRemoveVideo(item.mediaId)}
|
|
/>,
|
|
]}
|
|
>
|
|
<Space>
|
|
<div
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 4,
|
|
background: token.colorBgTextHover,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{index + 1}
|
|
</div>
|
|
{item.video.thumbnailUrl && (
|
|
<img
|
|
src={`/media${item.video.thumbnailUrl}`}
|
|
alt=""
|
|
style={{
|
|
width: 48,
|
|
height: 28,
|
|
objectFit: 'cover',
|
|
borderRadius: 4,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
<div style={{ minWidth: 0 }}>
|
|
<Text
|
|
ellipsis
|
|
style={{ fontSize: 13, display: 'block', maxWidth: 280 }}
|
|
>
|
|
{title}
|
|
</Text>
|
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
{formatDuration(item.video.durationSeconds)}
|
|
</Text>
|
|
</div>
|
|
</Space>
|
|
</List.Item>
|
|
);
|
|
}}
|
|
/>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Drawer>
|
|
);
|
|
}
|