changemaker.lite/admin/src/components/media/EditPlaylistModal.tsx

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>
);
}