219
resources/client/admin/titles/title-editor/videos-editor/title-videos-editor.tsx
Executable file
219
resources/client/admin/titles/title-editor/videos-editor/title-videos-editor.tsx
Executable file
@@ -0,0 +1,219 @@
|
||||
import {Link, useOutletContext, useParams} from 'react-router-dom';
|
||||
import {VideoThumbnail} from '@app/videos/video-thumbnail';
|
||||
import {PlayCircleIcon} from '@common/icons/material/PlayCircle';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import {AddFilterButton} from '@common/datatable/filters/add-filter-button';
|
||||
import {TuneIcon} from '@common/icons/material/Tune';
|
||||
import React, {Fragment, useMemo} from 'react';
|
||||
import {useBackendFilterUrlParams} from '@common/datatable/filters/backend-filter-url-params';
|
||||
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
|
||||
import {FilterList} from '@common/datatable/filters/filter-list/filter-list';
|
||||
import {TitleVideosSortButton} from '@app/admin/titles/title-editor/videos-editor/title-videos-sort-button';
|
||||
import {DeleteIcon} from '@common/icons/material/Delete';
|
||||
import {VideoGridItemBottomGradient} from '@app/titles/video-grid';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteVideos} from '@app/admin/videos/requests/use-delete-videos';
|
||||
import {VideosEditorSeasonSelect} from '@app/admin/titles/title-editor/videos-editor/videos-editor-season-select';
|
||||
import {VideosDatatableFilters} from '@app/admin/videos/videos-datatable-filters';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {TitleEditorPageStatus} from '@app/admin/titles/title-editor/title-editor-page-status';
|
||||
|
||||
export function TitleVideosEditor() {
|
||||
const filters = useMemo(
|
||||
() => VideosDatatableFilters.filter(f => f.key !== 'title_id'),
|
||||
[],
|
||||
);
|
||||
const {encodedFilters} = useBackendFilterUrlParams(filters);
|
||||
const {season, episode} = useParams();
|
||||
const title = useOutletContext<Title>();
|
||||
const query = useInfiniteData<Video>({
|
||||
queryKey: ['video', 'edit-title-page'],
|
||||
endpoint: 'videos',
|
||||
defaultOrderBy: 'created_at',
|
||||
defaultOrderDir: 'desc',
|
||||
queryParams: {
|
||||
perPage: 20,
|
||||
filters: encodedFilters,
|
||||
title_id: title.id,
|
||||
season: season ?? null,
|
||||
episode: episode ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
let content;
|
||||
|
||||
if (query.data) {
|
||||
content = query.items.length ? (
|
||||
<Fragment>
|
||||
<div className="grid grid-cols-1 gap-24 md:grid-cols-2 lg:grid-cols-3">
|
||||
{query.items.map(video => (
|
||||
<VideoItem key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
<InfiniteScrollSentinel query={query} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<NoVideosMessage isFiltering={encodedFilters != null} />
|
||||
);
|
||||
} else {
|
||||
content = <TitleEditorPageStatus query={query} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<div className="mb-24 flex flex-wrap items-center gap-12">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
elementType={Link}
|
||||
to="new"
|
||||
className="mr-auto"
|
||||
>
|
||||
<Trans message="Add video" />
|
||||
</Button>
|
||||
<VideosEditorSeasonSelect title={title} />
|
||||
<TitleVideosSortButton
|
||||
value={`${query.sortDescriptor.orderBy}:${query.sortDescriptor.orderDir}`}
|
||||
onValueChange={value => {
|
||||
const [orderBy, orderDir] = value.split(':');
|
||||
query.setSortDescriptor({orderBy, orderDir: orderDir as any});
|
||||
}}
|
||||
/>
|
||||
<AddFilterButton
|
||||
icon={<TuneIcon />}
|
||||
color={null}
|
||||
variant="outline"
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
<FilterList className="mb-24" filters={filters} />
|
||||
{content}
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoVideosMessageProps {
|
||||
isFiltering: boolean;
|
||||
}
|
||||
function NoVideosMessage({isFiltering}: NoVideosMessageProps) {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-40"
|
||||
imageMargin="mb-8"
|
||||
image={
|
||||
<div className="text-muted">
|
||||
<MediaPlayIcon size="xl" />
|
||||
</div>
|
||||
}
|
||||
imageHeight="h-auto"
|
||||
title={
|
||||
isFiltering ? (
|
||||
<Trans message="No matching videos" />
|
||||
) : (
|
||||
<Trans message="No videos have been added yet" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoItemProps {
|
||||
video: Video;
|
||||
}
|
||||
function VideoItem({video}: VideoItemProps) {
|
||||
const link = getWatchLink(video);
|
||||
return (
|
||||
<div className="">
|
||||
<Link to={link} className="relative isolate block" target="_blank">
|
||||
<VideoThumbnail video={video} title={video.title} srcSize="lg" />
|
||||
<VideoGridItemBottomGradient />
|
||||
<span className="absolute bottom-0 left-0 z-30 flex items-center gap-x-6 p-10 text-white">
|
||||
<PlayCircleIcon />
|
||||
<span className="capitalize">{video.category}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="mb-4 mt-12 flex items-center gap-24">
|
||||
<Link to={link} className="block font-semibold hover:underline">
|
||||
{video.name}
|
||||
</Link>
|
||||
{video.reports_count ? (
|
||||
<div className="ml-auto flex-shrink-0 whitespace-nowrap text-sm text-muted">
|
||||
<Trans
|
||||
message=":count reports"
|
||||
values={{count: video.reports_count}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-14 text-sm text-muted">
|
||||
{(video.season_num != null || video.episode_num != null) && (
|
||||
<CompactSeasonEpisode
|
||||
seasonNum={video.season_num}
|
||||
episodeNum={video.episode_num}
|
||||
/>
|
||||
)}
|
||||
<FormattedDate date={video.created_at} />
|
||||
</div>
|
||||
<div className="mt-14 flex items-center gap-24">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
startIcon={<EditIcon />}
|
||||
elementType={Link}
|
||||
to={`edit/${video.id}`}
|
||||
>
|
||||
<Trans message="Edit" />
|
||||
</Button>
|
||||
<DeleteButton video={video} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteButtonProps {
|
||||
video: Video;
|
||||
}
|
||||
function DeleteButton({video}: DeleteButtonProps) {
|
||||
const deleteVideos = useDeleteVideos();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={confirmed => {
|
||||
if (confirmed) {
|
||||
deleteVideos.mutate({videoIds: [video.id]});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
startIcon={<DeleteIcon />}
|
||||
disabled={deleteVideos.isPending}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete video" />}
|
||||
body={<Trans message="Are you sure you want to delete this video?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {message} from '@common/i18n/message';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {Button, ButtonProps} from '@common/ui/buttons/button';
|
||||
import {SortIcon} from '@common/icons/material/Sort';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
const SortOptions = [
|
||||
{
|
||||
value: 'created_at:desc',
|
||||
label: message('Newest'),
|
||||
},
|
||||
{
|
||||
value: 'created_at:asc',
|
||||
label: message('Oldest'),
|
||||
},
|
||||
{
|
||||
value: 'upvotes:desc',
|
||||
label: message('Most upvotes'),
|
||||
},
|
||||
{
|
||||
value: 'reports_count:desc',
|
||||
label: message('Most reported'),
|
||||
},
|
||||
{
|
||||
value: 'season_num:desc',
|
||||
label: message('Seasons'),
|
||||
},
|
||||
{
|
||||
value: 'order:asc',
|
||||
label: message('Curated'),
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onValueChange: (newValue: string) => void;
|
||||
color?: ButtonProps['color'];
|
||||
}
|
||||
export function TitleVideosSortButton({value, onValueChange, color}: Props) {
|
||||
let selectedOption = SortOptions.find(option => option.value === value);
|
||||
if (!selectedOption) {
|
||||
selectedOption = SortOptions[0];
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuTrigger
|
||||
selectedValue={value}
|
||||
onSelectionChange={newValue => onValueChange(newValue as string)}
|
||||
selectionMode="single"
|
||||
>
|
||||
<Button variant="outline" startIcon={<SortIcon />} color={color}>
|
||||
<Trans {...selectedOption.label} />
|
||||
</Button>
|
||||
<Menu>
|
||||
{SortOptions.map(option => (
|
||||
<MenuItem value={option.value} key={option.value}>
|
||||
<Trans {...option.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {Select} from '@common/ui/forms/select/select';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Option} from '@common/ui/forms/combobox/combobox';
|
||||
import React from 'react';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {useSeasonEpisodeNumbers} from '@app/seasons/requests/use-season-episode-numbers';
|
||||
|
||||
interface Props {
|
||||
title: Title;
|
||||
}
|
||||
export function VideosEditorSeasonSelect({title}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const {trans} = useTrans();
|
||||
const params = useParams();
|
||||
const season = params.season ? Number(params.season) : '';
|
||||
const episode = params.episode ? Number(params.episode) : '';
|
||||
|
||||
const handleNavigate = (season?: number, episode?: number) => {
|
||||
let uri = `/admin/titles/${title.id}/edit/videos`;
|
||||
if (season) {
|
||||
uri += `/seasons/${season}`;
|
||||
}
|
||||
if (episode) {
|
||||
uri += `/episodes/${episode}`;
|
||||
}
|
||||
navigate(uri);
|
||||
};
|
||||
|
||||
if (!title.seasons_count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-12">
|
||||
<Select
|
||||
className="flex-1"
|
||||
selectedValue={season}
|
||||
onSelectionChange={newSeason => {
|
||||
handleNavigate(newSeason as number);
|
||||
}}
|
||||
placeholder={trans(message('Season'))}
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
>
|
||||
<Option key="none" value="">
|
||||
<Trans message="All seasons" />
|
||||
</Option>
|
||||
{[...new Array(title.seasons_count).keys()].map(i => {
|
||||
const number = i + 1;
|
||||
return (
|
||||
<Option key={number} value={number}>
|
||||
<Trans message="Season :number" values={{number}} />
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
{season && (
|
||||
<EpisodeSelect
|
||||
value={episode}
|
||||
onChange={newEpisode => {
|
||||
handleNavigate(season, newEpisode as number);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EpisodeSelectProps {
|
||||
value: string | number;
|
||||
onChange: (value: string | number) => void;
|
||||
}
|
||||
function EpisodeSelect({value, onChange}: EpisodeSelectProps) {
|
||||
const {trans} = useTrans();
|
||||
const {data} = useSeasonEpisodeNumbers();
|
||||
return (
|
||||
<Select
|
||||
placeholder={trans(message('Episode'))}
|
||||
selectionMode="single"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
selectedValue={value}
|
||||
onSelectionChange={onChange}
|
||||
>
|
||||
<Option key="none" value="">
|
||||
<Trans message="All episodes" />
|
||||
</Option>
|
||||
{data?.episodeNumbers.map(number => {
|
||||
return (
|
||||
<Option key={number} value={number}>
|
||||
<Trans message="Episode :number" values={{number}} />
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user