first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

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

View File

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

View File

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