started update on bookmarks

This commit is contained in:
2025-07-24 22:20:07 +03:00
parent 096f874eeb
commit e2a7887108
10 changed files with 464 additions and 482 deletions

View File

@@ -9,6 +9,7 @@ import {
import React from 'react';
import { CardContentContainerComment } from '../common/Comments/Comments-styles';
import { useIsSmall } from '../../hooks/useIsSmall';
import { AddToBookmarks } from '../common/ContentButtons/AddToBookmarks';
interface PlaylistsProps {
playlistData;
@@ -26,7 +27,7 @@ export const Playlists = ({
const theme = useTheme();
const isScreenSmall = !useMediaQuery(`(min-width:700px)`);
const PlaylistsHeight = '36vw'; // This is videoplayer width * 9/16 (inverse of aspect ratio)
console.log('playlistData', playlistData);
return (
<Box
sx={{
@@ -38,6 +39,14 @@ export const Playlists = ({
height: '100%',
}}
>
<AddToBookmarks
metadataReference={{
identifier: playlistData?.identifier,
service: 'PLAYLIST',
name: playlistData?.name,
}}
type="playlist"
/>
<CardContentContainerComment
sx={{
marginTop: '0px',

View File

@@ -165,6 +165,7 @@ export const PublishVideo = ({
},
maxSize,
onDrop: async (acceptedFiles, rejectedFiles) => {
const unsupportedFiles = [];
const formattedFiles = [];
for (const file of acceptedFiles) {
@@ -176,9 +177,11 @@ export const PublishVideo = ({
}
const notSupportedCodec = await isHEVC(file);
console.log('isGood', isGood);
if (notSupportedCodec) {
unsupportedFiles.push(file);
continue;
}
formattedFiles.push({
isHEVC: notSupportedCodec,
file,
title: filteredTitle || '',
description: '',

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import BookmarksIcon from '@mui/icons-material/Bookmarks';
import {
alpha,
Box,
Button,
ButtonBase,
@@ -15,129 +14,128 @@ import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import CloseIcon from '@mui/icons-material/Close';
import { usePersistedState } from '../../../state/persist/persist';
import ShortUniqueId from 'short-unique-id';
import { Service, Spacer, useGlobal } from 'qapp-core';
import { Service, useGlobal } from 'qapp-core';
import { useTranslation } from 'react-i18next';
import { CustomTooltip } from './CustomTooltip';
import { BookmarkList } from '../../../types/bookmark';
const uid = new ShortUniqueId({ length: 15, dictionary: 'alphanum' });
const MAX_NESTING_LEVEL = 1;
export const AddToBookmarks = ({ metadataReference }) => {
export const AddToBookmarks = ({ metadataReference, type = 'video' }) => {
const { t } = useTranslation(['core']);
const { lists: globalLists } = useGlobal();
const [bookmarks, setBookmarks, isHydratedSubscriptions] = usePersistedState<
const [bookmarks, setBookmarks] = usePersistedState<
Record<string, BookmarkList>
>('bookmarks-v1', {});
const [bookmarkList, setBookmarkList] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [mode, setMode] = useState(1);
const [mode, setMode] = useState(1); // 1: main, 2: new list, 3: new folder
const [title, setTitle] = useState('');
const [parentId, setParentId] = useState<string | null>(null);
const theme = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const ref = useRef<any>(null);
useEffect(() => {
if (isOpen) {
ref?.current?.focus();
}
if (isOpen) ref?.current?.focus();
}, [isOpen]);
const handleBlur = (e: React.FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget) && isOpen) {
setIsOpen(false);
}
};
useEffect(() => {
if (mode !== 2) return;
inputRef.current?.focus();
if (mode === 2 || mode === 3) inputRef.current?.focus();
}, [mode]);
const handleCreateList = () => {
const handleCreateList = (parentId?: string) => {
const newId = uid.rnd();
setBookmarks((prev) => {
return {
const updated: Record<string, BookmarkList> = {
...prev,
[newId]: {
id: newId,
title: title,
title,
description: '',
created: Date.now(),
updated: Date.now(),
lastAdded: 0,
videos: [],
lastAccessed: 0,
type: 'list', // list, playlist, folder
playlistReference: null,
folderName: null,
type: 'list',
},
};
if (parentId && prev[parentId]?.type === 'folder') {
updated[parentId] = {
...prev[parentId],
children: [...(prev[parentId].children || []), newId],
updated: Date.now(),
};
}
return updated;
});
setMode(1);
setTitle('');
setParentId(null);
};
const handleCreateFolder = () => {
const newId = uid.rnd();
setBookmarks((prev) => ({
...prev,
[newId]: {
id: newId,
title,
description: '',
created: Date.now(),
updated: Date.now(),
lastAdded: 0,
videos: [],
lastAccessed: 0,
type: 'folder',
children: [],
},
}));
setMode(1);
setTitle('');
};
const handleInputKeyDown = (event: any) => {
if (event.key === 'Enter' && title?.trim()) {
handleCreateList();
}
};
const handleAddVideoToList = (
listId: string,
video: {
name: string;
identifier: string;
service: Service;
}
) => {
const handleAddVideoToList = (listId: string, video: any) => {
globalLists.deleteList('bookmarks-all');
globalLists.deleteList(`bookmarks-all-${listId}`);
setBookmarks((prev) => {
const list = prev[listId];
if (!list || list.type !== 'list') return prev;
const videoExists = list.videos.some(
(v) =>
v.name === video.name &&
v.identifier === video.identifier &&
v.service === video.service
);
if (videoExists) return prev;
const newVideo = {
...video,
addedAt: Date.now(),
created: Date.now(),
size: 200,
};
if (
list.videos.some(
(v) =>
v.name === video.name &&
v.identifier === video.identifier &&
v.service === video.service
)
) {
return prev;
}
return {
...prev,
[listId]: {
...list,
videos: [...list.videos, newVideo],
videos: [
...list.videos,
{
...video,
addedAt: Date.now(),
created: Date.now(),
size: 200,
type,
},
],
updated: Date.now(),
},
};
});
};
const handleRemoveVideoFromList = (
listId: string,
video: {
name: string;
identifier: string;
service: string;
}
) => {
const handleRemoveVideoFromList = (listId: string, video: any) => {
setBookmarks((prev) => {
const list = prev[listId];
if (!list || list.type !== 'list') return prev;
const updatedVideos = list.videos.filter(
(v) =>
!(
@@ -146,9 +144,7 @@ export const AddToBookmarks = ({ metadataReference }) => {
v.service === video.service
)
);
// If no change, avoid unnecessary update
if (updatedVideos.length === list.videos.length) return prev;
return {
...prev,
[listId]: {
@@ -160,26 +156,20 @@ export const AddToBookmarks = ({ metadataReference }) => {
});
};
const isVideoInList = (
listId: string,
video: { name: string; identifier: string; service: string }
): boolean => {
const isVideoInList = (listId: string, video: any): boolean => {
const list = bookmarks[listId];
if (!list || list.type !== 'list') return false;
return list.videos.some(
(v) =>
v.name === video.name &&
v.identifier === video.identifier &&
v.service === video.service
return (
list?.type === 'list' &&
list.videos.some(
(v) =>
v.name === video.name &&
v.identifier === video.identifier &&
v.service === video.service
)
);
};
const isVideoInAnyList = (video: {
name: string;
identifier: string;
service: string;
}): boolean => {
const isVideoInAnyList = (video: any): boolean => {
return Object.values(bookmarks).some(
(b) =>
b.type === 'list' &&
@@ -192,32 +182,96 @@ export const AddToBookmarks = ({ metadataReference }) => {
);
};
const isInABookmark = useMemo(() => {
return isVideoInAnyList(metadataReference);
}, [bookmarks]);
const buildBookmarkTree = (
bookmarks: Record<string, BookmarkList>
): BookmarkList[] => {
const roots: BookmarkList[] = [];
const childSet = new Set(
Object.values(bookmarks).flatMap((b) => b.children ?? [])
);
Object.values(bookmarks).forEach((item) => {
if (
!childSet.has(item.id) &&
(item.type === 'list' || item.type === 'folder')
) {
roots.push(item);
}
});
return roots.sort((a, b) => a.title.localeCompare(b.title));
};
const lists = Object.values(bookmarks)
.filter((bookmark) => bookmark?.type === 'list' && !!bookmark?.title)
.sort((a, b) => a.title.localeCompare(b.title));
const renderBookmarkNode = (
node: BookmarkList,
depth = 0
): React.ReactNode => {
if (node.type === 'folder') {
return (
<Box key={node.id} sx={{ ml: depth + 1, mt: 1 }}>
<Box
sx={{
display: 'flex',
gap: '10px',
alignItems: 'center',
}}
>
<Typography sx={{ fontWeight: 'bold' }}>{node.title}</Typography>
{depth < MAX_NESTING_LEVEL && (
<Button
size="small"
onClick={() => {
setMode(2);
setParentId(node.id);
}}
>
{t('core:bookmarks.new_list')}
</Button>
)}
</Box>
{depth < MAX_NESTING_LEVEL &&
(node.children || []).map((childId) => {
const child = bookmarks[childId];
return child ? renderBookmarkNode(child, depth + 1) : null;
})}
</Box>
);
}
const isInList = isVideoInList(node.id, metadataReference);
return (
<ButtonBase
key={node.id}
onClick={() =>
isInList
? handleRemoveVideoFromList(node.id, metadataReference)
: handleAddVideoToList(node.id, metadataReference)
}
sx={{ pl: depth * 2, justifyContent: 'flex-start', width: '100%' }}
>
<Checkbox checked={isInList} />
<Typography>{node.title}</Typography>
</ButtonBase>
);
};
return (
<>
<CustomTooltip
title={t('core:action.bookmark_video', {
title={t('core:action.bookmark_playlist', {
postProcess: 'capitalizeFirstChar',
})}
arrow
placement={'top'}
placement="top"
>
<ButtonBase onClick={() => setIsOpen(true)}>
<BookmarksIcon color={isInABookmark ? 'success' : 'info'} />
<BookmarksIcon
color={isVideoInAnyList(metadataReference) ? 'success' : 'info'}
/>
</ButtonBase>
</CustomTooltip>
{isOpen && (
<Box
ref={ref}
tabIndex={-1}
// onBlur={handleBlur}
bgcolor={theme.palette.background.paper}
sx={{
position: 'fixed',
@@ -238,230 +292,112 @@ export const AddToBookmarks = ({ metadataReference }) => {
{mode === 1 && (
<>
<ButtonBase
sx={{
padding: '5px 0px 10px 0px',
display: 'flex',
justifyContent: 'space-between',
gap: '10px',
width: '100%',
}}
onClick={() => {
setIsOpen(false);
setMode(1);
setTitle('');
}}
sx={{
p: '5px 0 10px',
justifyContent: 'space-between',
width: '100%',
}}
>
<Typography
sx={{
fontSize: '0.85rem',
}}
>
<Typography sx={{ fontSize: '0.85rem' }}>
{t('core:bookmarks.bookmark_lists', {
postProcess: 'capitalizeFirstChar',
})}
</Typography>
<CloseIcon
sx={{
fontSize: '1.15em',
}}
/>
<CloseIcon sx={{ fontSize: '1.15em' }} />
</ButtonBase>
<Divider />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'auto',
'::-webkit-scrollbar-track': {
backgroundColor: 'transparent',
},
'::-webkit-scrollbar': {
width: '16px',
height: '10px',
},
'::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.primary.main,
borderRadius: '8px',
backgroundClip: 'content-box',
border: '4px solid transparent',
transition: '0.3s background-color',
},
'::-webkit-scrollbar-thumb:hover': {
backgroundColor: theme.palette.primary.dark,
},
}}
>
{lists?.length === 0 && (
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
{buildBookmarkTree(bookmarks).length === 0 ? (
<Typography
sx={{
fontSize: '1rem',
width: '100%',
textAlign: 'center',
marginTop: '20px',
}}
sx={{ fontSize: '1rem', textAlign: 'center', mt: 2 }}
>
{t('core:bookmarks.no_bookmarks_lists', {
postProcess: 'capitalizeFirstChar',
})}
</Typography>
) : (
buildBookmarkTree(bookmarks).map((node) =>
renderBookmarkNode(node, 0)
)
)}
<Spacer height="10px" />
<Box
sx={{
width: '100%',
}}
>
{lists?.map((list) => {
const isInList = isVideoInList(list.id, metadataReference);
return (
<ButtonBase
sx={{
width: '100%',
justifyContent: 'flex-start',
}}
key={list.id}
onClick={() =>
isInList
? handleRemoveVideoFromList(
list.id,
metadataReference
)
: handleAddVideoToList(list.id, metadataReference)
}
>
<Checkbox checked={isInList} />
<Typography>{list?.title}</Typography>
</ButtonBase>
);
})}
</Box>
</Box>
<Box
sx={{
display: 'flex',
width: '100%',
justifyContent: 'center',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
onClick={() => setMode(2)}
variant="contained"
size="small"
variant="contained"
>
{t('core:bookmarks.new_list', {
postProcess: 'capitalizeFirstChar',
})}
{t('core:bookmarks.new_list')}
</Button>
<Button
onClick={() => setMode(3)}
size="small"
variant="contained"
>
{t('core:bookmarks.new_folder')}
</Button>
</Box>
</>
)}
{mode === 2 && (
{(mode === 2 || mode === 3) && (
<>
<Box
sx={{
padding: '5px 0px 10px 0px',
display: 'flex',
gap: '10px',
width: '100%',
}}
>
<Box sx={{ display: 'flex', gap: '10px', width: '100%', py: 1 }}>
<ButtonBase
onClick={() => {
setIsOpen(false);
setMode(1);
setTitle('');
setParentId(null);
}}
>
<ArrowBackIosIcon
sx={{
fontSize: '1.15em',
}}
/>
</ButtonBase>
<ButtonBase>
<Typography
onClick={() => {
setIsOpen(false);
setMode(1);
setTitle('');
}}
sx={{
fontSize: '0.85rem',
}}
>
{t('core:bookmarks.bookmark_lists', {
postProcess: 'capitalizeFirstChar',
})}
</Typography>
<ArrowBackIosIcon sx={{ fontSize: '1.15em' }} />
</ButtonBase>
<Typography sx={{ fontSize: '0.85rem' }}>
{t('core:bookmarks.bookmark_lists', {
postProcess: 'capitalizeFirstChar',
})}
</Typography>
</Box>
<Divider />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'auto',
'::-webkit-scrollbar-track': {
backgroundColor: 'transparent',
},
'::-webkit-scrollbar': {
width: '16px',
height: '10px',
},
'::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.primary.main,
borderRadius: '8px',
backgroundClip: 'content-box',
border: '4px solid transparent',
transition: '0.3s background-color',
},
'::-webkit-scrollbar-thumb:hover': {
backgroundColor: theme.palette.primary.dark,
},
}}
>
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
<TextField
inputRef={inputRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleInputKeyDown}
onKeyDown={(e) => {
if (e.key === 'Enter' && title.trim()) {
mode === 2
? handleCreateList(parentId ?? undefined)
: handleCreateFolder();
}
}}
fullWidth
/>
</Box>
<Box
sx={{
display: 'flex',
width: '100%',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
onClick={() => setMode(1)}
variant="contained"
size="small"
>
{t('core:action.cancel', {
postProcess: 'capitalizeFirstChar',
})}
{t('core:action.cancel')}
</Button>
<Button
onClick={handleCreateList}
onClick={() =>
mode === 2
? handleCreateList(parentId ?? undefined)
: handleCreateFolder()
}
disabled={!title}
variant="contained"
size="small"
>
{t('core:action.create', {
postProcess: 'capitalizeFirstChar',
})}
{t('core:action.create')}
</Button>
</Box>
</>

View File

@@ -121,9 +121,7 @@ export default function FileElement({
return (
<Box
// onClick={handlePlay}
sx={{
width: '100%',
overflow: 'hidden',
position: 'relative',
cursor: 'pointer',

View File

@@ -21,14 +21,15 @@ export const FrameExtractor = ({
useEffect(() => {
const video = videoRef.current;
video.addEventListener('loadedmetadata', () => {
if (!video) return;
const handleLoadedMetadata = () => {
const duration = video.duration;
if (isFinite(duration)) {
// Proceed with your logic
const newVideoDurations = [...videoDurations];
newVideoDurations[index] = duration;
setVideoDurations([...newVideoDurations]);
setVideoDurations(newVideoDurations);
const section = duration / 4;
const timestamps = [];
@@ -41,9 +42,13 @@ export const FrameExtractor = ({
} else {
onFramesExtracted([]);
}
});
}, [videoFile]);
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
};
}, [videoFile]);
useEffect(() => {
if (durations.length === 4) {
extractFrames();
@@ -54,12 +59,24 @@ export const FrameExtractor = ({
return URL.createObjectURL(videoFile);
}, [videoFile]);
useEffect(() => {
return () => {
URL.revokeObjectURL(fileUrl);
};
}, [fileUrl]);
const extractFrames = async () => {
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
if (!video || !canvas) return;
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
const context = canvas.getContext('2d');
if (!context) return;
const frameData = [];
@@ -69,7 +86,9 @@ export const FrameExtractor = ({
const onSeeked = () => {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
frameData.push(blob);
if (blob) {
frameData.push(blob);
}
resolve();
}, 'image/png');
video.removeEventListener('seeked', onSeeked);
@@ -78,9 +97,8 @@ export const FrameExtractor = ({
});
}
onFramesExtracted(frameData);
await onFramesExtracted(frameData);
};
return (
<div>
<video ref={videoRef} style={{ display: 'none' }} src={fileUrl}></video>

View File

@@ -33,7 +33,7 @@
"supported_containers": "Supported File Containers",
"audio_codecs": "audio codecs",
"video_codecs": "video codecs",
"unsupported_codecs_description": "Using unsupported Codecs may result in video or audio notworking properly",
"unsupported_codecs_description": "Using unsupported Codecs may result in video or audio not working properly",
"select_category": "Select a Category",
"select_subcategory": "Select a Sub-Category",
"add_cover_image": "add cover image",

View File

@@ -2,6 +2,8 @@ import {
Box,
Button,
ButtonBase,
Card,
CardContent,
Dialog,
DialogActions,
DialogContent,
@@ -12,7 +14,10 @@ import {
MenuItem,
Select,
TextField,
Typography,
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import ListIcon from '@mui/icons-material/List';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Spacer, useGlobal } from 'qapp-core';
@@ -28,13 +33,15 @@ import { BookmarkList } from '../../types/bookmark.ts';
export const Bookmarks = () => {
const { t } = useTranslation(['core']);
const isSmall = useIsSmall();
const [selectedList, setSelectedList, isHydratedSelectedList] =
usePersistedState<number | any>('selectedBookmarkList', 0);
const [bookmarks, setBookmarks, isHydratedBookmarks] = usePersistedState<
const [selectedList, setSelectedList] = usePersistedState<any>(
'selectedBookmarkList',
0
);
const [bookmarks, setBookmarks] = usePersistedState<
Record<string, BookmarkList>
>('bookmarks-v1', {});
const [folderView, setFolderView] = useState<string | null>(null);
const inputRef = useRef(null);
const [isOpenEdit, setIsOpenEdit] = useState(false);
const [newTitle, setNewTitle] = useState('');
@@ -61,6 +68,22 @@ export const Bookmarks = () => {
.sort((a, b) => a.title.localeCompare(b.title));
}, [bookmarks]);
const rootLists = useMemo(() => {
const childIds = new Set(
Object.values(bookmarks).flatMap((b) => b.children ?? [])
);
return Object.values(bookmarks).filter(
(b) => (b.type === 'list' || b.type === 'folder') && !childIds.has(b.id)
);
}, [bookmarks]);
const currentLists = useMemo(() => {
if (!folderView) return rootLists;
const folder = bookmarks[folderView];
if (!folder || folder.type !== 'folder') return [];
return (folder.children || []).map((id) => bookmarks[id]).filter(Boolean);
}, [folderView, bookmarks, rootLists]);
const handleChange = (event) => {
const listId = event.target.value;
if (!listId) {
@@ -79,9 +102,8 @@ export const Bookmarks = () => {
const handleDeleteList = (listId: string) => {
setBookmarks((prev) => {
if (!prev[listId]) return prev; // List doesn't exist
const { [listId]: _, ...rest } = prev; // Remove the key using object destructuring
if (!prev[listId]) return prev;
const { [listId]: _, ...rest } = prev;
return rest;
});
};
@@ -90,7 +112,6 @@ export const Bookmarks = () => {
setBookmarks((prev) => {
const list = prev[listId];
if (!list || list.type !== 'list') return prev;
return {
...prev,
[listId]: {
@@ -110,18 +131,10 @@ export const Bookmarks = () => {
}
};
const handleRemoveVideoFromList = (
listId: string,
video: {
name: string;
identifier: string;
service: string;
}
) => {
const handleRemoveVideoFromList = (listId: string, video: any) => {
setBookmarks((prev) => {
const list = prev[listId];
if (!list || list.type !== 'list') return prev;
const updatedVideos = list.videos.filter(
(v) =>
!(
@@ -130,9 +143,7 @@ export const Bookmarks = () => {
v.service === video.service
)
);
// If no change, avoid unnecessary update
if (updatedVideos.length === list.videos.length) return prev;
return {
...prev,
[listId]: {
@@ -152,7 +163,27 @@ export const Bookmarks = () => {
setIsOpenEdit(false);
};
if (!selectedList && isHydratedBookmarks) {
const handleDeleteFolder = (folderId: string) => {
if (!bookmarks[folderId] || bookmarks[folderId].type !== 'folder') return;
const folder = bookmarks[folderId];
const children = folder.children || [];
setBookmarks((prev) => {
const updated = { ...prev };
delete updated[folderId];
children.forEach((childId) => {
delete updated[childId];
});
return updated;
});
setFolderView(null);
};
console.log('bookmark', bookmarks);
if (!selectedList) {
return (
<PageTransition>
<Box
@@ -161,79 +192,79 @@ export const Bookmarks = () => {
display: 'flex',
flexDirection: 'column',
width: '100%',
alignItems: isSmall ? 'center' : 'flex-start',
alignItems: 'flex-start',
}}
>
<FormControl
sx={{
maxWidth: '100%',
width: '320px',
}}
>
<InputLabel id="bookmark-list-label">
{t('core:bookmarks.select_list', {
postProcess: 'capitalizeFirstChar',
})}
</InputLabel>
<Box display="flex" alignItems="center" gap={2}>
{(folderView !== null || selectedList !== 0) && (
<Button
onClick={() => {
setFolderView(null);
setSelectedList(0);
}}
size="small"
>
{' '}
{t('core:action.back', { postProcess: 'capitalizeFirstChar' })}
</Button>
)}
<Select
labelId="bookmark-list-label"
value={selectedList?.id || 0}
label={t('core:bookmarks.select_list', {
postProcess: 'capitalizeFirstChar',
})}
onChange={handleChange}
displayEmpty
{folderView && (
<Button
color="error"
size="small"
onClick={() => {
const confirm = window.confirm(
t('core:bookmarks.confirm_delete_folder') ||
'Are you sure you want to delete this folder and all its lists?'
);
if (confirm) handleDeleteFolder(folderView);
}}
>
{t('core:action.delete', {
postProcess: 'capitalizeFirstChar',
})}
</Button>
)}
</Box>
{/* {folderView && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<MenuItem value={0}>
<em>
{t('core:publish.all_videos', {
postProcess: 'capitalizeFirstChar',
})}
</em>
</MenuItem>
{sortedLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.title}
</MenuItem>
))}
</Select>
</FormControl>
<Spacer height="20px" />
<FolderIcon />
<Typography>{bookmarks[folderView]?.title}</Typography>
</Box>
)} */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: 2,
width: '100%',
flexDirection: 'column',
display: 'flex',
alignItems: isSmall ? 'center' : 'flex-start',
}}
>
<PageSubTitle>
{t('core:bookmarks.all_bookmarks', {
postProcess: 'capitalizeFirstChar',
})}
</PageSubTitle>
<Spacer height="14px" />
<Divider flexItem />
<Spacer height="20px" />
</Box>
<Box>
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<VideoListPreloaded
listName="bookmarks-all"
videoList={allVideos}
disableActions
/>
</Box>
{currentLists.map((item) => (
<Card
key={item.id}
sx={{ cursor: 'pointer' }}
onClick={() => {
if (item.type === 'folder') setFolderView(item.id);
else setSelectedList(item);
}}
>
<CardContent
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
{item.type === 'folder' ? <FolderIcon /> : <ListIcon />}
<Typography>{item?.title}</Typography>
</CardContent>
</Card>
))}
</Box>
</Box>
</PageTransition>
@@ -242,85 +273,55 @@ export const Bookmarks = () => {
return (
<PageTransition>
<FormControl fullWidth>
<InputLabel id="bookmark-list-label">
{t('core:bookmarks.select_list', {
postProcess: 'capitalizeFirstChar',
})}
</InputLabel>
<Button
onClick={() => {
setSelectedList(0);
setFolderView(null);
}}
size="small"
>
{t('core:action.back', { postProcess: 'capitalizeFirstChar' })}
</Button>
<Spacer height="10px" />
<Select
labelId="bookmark-list-label"
value={selectedList?.id || 0}
label={t('core:bookmarks.select_list', {
postProcess: 'capitalizeFirstChar',
})}
onChange={handleChange}
displayEmpty
>
<MenuItem value={0}>
<em>
{t('core:publish.all_videos', {
postProcess: 'capitalizeFirstChar',
})}
</em>
</MenuItem>
{sortedLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.title}
</MenuItem>
))}
</Select>
</FormControl>
<PageSubTitle>
{t('core:bookmarks.bookmark_list', {
postProcess: 'capitalizeFirstChar',
})}
: {selectedList?.title}
</PageSubTitle>
<Spacer height="10px" />
<Button
size="small"
onClick={() => setIsOpenEdit(true)}
variant="outlined"
startIcon={<EditIcon />}
>
Edit
</Button>
<Spacer height="10px" />
<Divider flexItem />
<Spacer height="20px" />
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<PageSubTitle
sx={{
alignSelf: 'flex-start',
}}
>
{t('core:bookmarks.bookmark_list', {
postProcess: 'capitalizeFirstChar',
})}
: {selectedList?.title}
</PageSubTitle>
<Spacer height="10px" />
<Button
size="small"
onClick={() => setIsOpenEdit(true)}
variant="outlined"
startIcon={<EditIcon />}
>
Edit
</Button>
<Spacer height="10px" />
<Divider flexItem />
<Spacer height="20px" />
</Box>
<Box>
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<VideoListPreloaded
listName={`bookmarks-all-${selectedList?.id}`}
videoList={allVideos}
handleRemoveVideoFromList={handleRemoveVideoFromList}
listId={selectedList?.id}
/>
</Box>
<VideoListPreloaded
listName={`bookmarks-all-${selectedList?.id}`}
videoList={allVideos}
handleRemoveVideoFromList={handleRemoveVideoFromList}
listId={selectedList?.id}
/>
</Box>
<Dialog
open={isOpenEdit}
onClose={() => setIsOpenEdit(false)}
@@ -351,13 +352,9 @@ export const Bookmarks = () => {
variant="contained"
color="error"
onClick={deleteList}
sx={{
marginRight: 'auto',
}}
sx={{ marginRight: 'auto' }}
>
{t('core:action.delete', {
postProcess: 'capitalizeFirstChar',
})}
{t('core:action.delete', { postProcess: 'capitalizeFirstChar' })}
</Button>
<Button
variant="contained"
@@ -366,9 +363,7 @@ export const Bookmarks = () => {
setNewTitle(selectedList?.title);
}}
>
{t('core:action.close', {
postProcess: 'capitalizeFirstChar',
})}
{t('core:action.close', { postProcess: 'capitalizeFirstChar' })}
</Button>
<Button
disabled={!newTitle.trim() || selectedList?.title === newTitle}
@@ -384,9 +379,7 @@ export const Bookmarks = () => {
setIsOpenEdit(false);
}}
>
{t('core:action.save', {
postProcess: 'capitalizeFirstChar',
})}
{t('core:action.save', { postProcess: 'capitalizeFirstChar' })}
</Button>
</DialogActions>
</Dialog>

View File

@@ -122,6 +122,7 @@ export const VideoActionsBar = ({
service: 'DOCUMENT',
name: videoData?.user,
}}
type="video"
/>
<IndexButton channelName={channelName} />
<CopyLinkButton

View File

@@ -72,47 +72,70 @@ export const VideoListItem = ({
zIndex: 2,
}}
>
{qortalMetadata?.name === username && (
<Tooltip
title={t('core:publish.edit_playlist', {
postProcess: 'capitalizeFirstChar',
})}
placement="top"
>
<BlockIconContainer>
<EditIcon
onClick={() => {
const resourceData = {
title: qortalMetadata?.metadata?.title,
category: qortalMetadata?.metadata?.category,
categoryName: qortalMetadata?.metadata?.categoryName,
tags: qortalMetadata?.metadata?.tags || [],
description: qortalMetadata?.metadata?.description,
created: qortalMetadata?.created,
updated: qortalMetadata?.updated,
name: qortalMetadata.name,
videoImage: '',
identifier: qortalMetadata.identifier,
service: qortalMetadata.service,
};
setEditPlaylist({ ...resourceData, ...video });
}}
/>
</BlockIconContainer>
</Tooltip>
)}
{qortalMetadata?.name === username &&
!isBookmarks &&
!disableActions && (
<Tooltip
title={t('core:publish.edit_playlist', {
postProcess: 'capitalizeFirstChar',
})}
placement="top"
>
<BlockIconContainer>
<EditIcon
onClick={() => {
const resourceData = {
title: qortalMetadata?.metadata?.title,
category: qortalMetadata?.metadata?.category,
categoryName: qortalMetadata?.metadata?.categoryName,
tags: qortalMetadata?.metadata?.tags || [],
description: qortalMetadata?.metadata?.description,
created: qortalMetadata?.created,
updated: qortalMetadata?.updated,
name: qortalMetadata.name,
videoImage: '',
identifier: qortalMetadata.identifier,
service: qortalMetadata.service,
};
setEditPlaylist({ ...resourceData, ...video });
}}
/>
</BlockIconContainer>
</Tooltip>
)}
{qortalMetadata?.name !== username && (
{qortalMetadata?.name !== username &&
!isBookmarks &&
!disableActions && (
<Tooltip
title={t('core:publish.block_user_content', {
postProcess: 'capitalizeFirstChar',
})}
placement="top"
>
<BlockIconContainer>
<BlockIcon
onClick={() => {
blockUserFunc(qortalMetadata?.name);
}}
/>
</BlockIconContainer>
</Tooltip>
)}
{isBookmarks && handleRemoveVideoFromList && !disableActions && (
<Tooltip
title={t('core:publish.block_user_content', {
title={t('core:publish.remove_playlist_list', {
postProcess: 'capitalizeFirstChar',
})}
placement="top"
>
<BlockIconContainer>
<BlockIcon
<DeleteIcon
onClick={() => {
blockUserFunc(qortalMetadata?.name);
handleRemoveVideoFromList([
qortalMetadata,
video.videoReference,
]);
}}
/>
</BlockIconContainer>

View File

@@ -2,6 +2,7 @@ import { QortalMetadata } from 'qapp-core';
interface VideoBookmark extends QortalMetadata {
addedAt: number;
type: 'video' | 'playlist';
}
export interface BookmarkList {
@@ -13,7 +14,7 @@ export interface BookmarkList {
lastAdded: number;
videos: VideoBookmark[];
lastAccessed: number;
type: 'list' | 'playlist' | 'folder';
playlistReference: string | null;
folderName: string | null;
type: 'list' | 'folder';
// New: for folders only
children?: string[];
}