feat: Add functionality to refresh video thumbnail
This commit is contained in:
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -8,12 +8,12 @@ import downloadManager from "../services/downloadManager";
|
||||
import * as downloadService from "../services/downloadService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import {
|
||||
extractBilibiliVideoId,
|
||||
extractUrlFromText,
|
||||
isBilibiliUrl,
|
||||
isValidUrl,
|
||||
resolveShortUrl,
|
||||
trimBilibiliUrl
|
||||
extractBilibiliVideoId,
|
||||
extractUrlFromText,
|
||||
isBilibiliUrl,
|
||||
isValidUrl,
|
||||
resolveShortUrl,
|
||||
trimBilibiliUrl
|
||||
} from "../utils/helpers";
|
||||
|
||||
// Configure Multer for file uploads
|
||||
@@ -529,3 +529,94 @@ export const updateVideoDetails = (req: Request, res: Response): any => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Refresh video thumbnail
|
||||
export const refreshThumbnail = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const video = storageService.getVideoById(id);
|
||||
|
||||
if (!video) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
// Construct paths
|
||||
let videoFilePath: string;
|
||||
if (video.videoPath && video.videoPath.startsWith('/videos/')) {
|
||||
const relativePath = video.videoPath.replace(/^\/videos\//, '');
|
||||
// Split by / to handle the web path separators and join with system separator
|
||||
videoFilePath = path.join(VIDEOS_DIR, ...relativePath.split('/'));
|
||||
} else if (video.videoFilename) {
|
||||
videoFilePath = path.join(VIDEOS_DIR, video.videoFilename);
|
||||
} else {
|
||||
return res.status(400).json({ error: "Video file path not found in record" });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(videoFilePath)) {
|
||||
return res.status(404).json({ error: "Video file not found on disk" });
|
||||
}
|
||||
|
||||
// Determine thumbnail path on disk
|
||||
let thumbnailAbsolutePath: string;
|
||||
let needsDbUpdate = false;
|
||||
let newThumbnailFilename = video.thumbnailFilename;
|
||||
let newThumbnailPath = video.thumbnailPath;
|
||||
|
||||
if (video.thumbnailPath && video.thumbnailPath.startsWith('/images/')) {
|
||||
// Local file exists (or should exist) - preserve the existing path (e.g. inside a collection folder)
|
||||
const relativePath = video.thumbnailPath.replace(/^\/images\//, '');
|
||||
thumbnailAbsolutePath = path.join(IMAGES_DIR, ...relativePath.split('/'));
|
||||
} else {
|
||||
// Remote URL or missing - create a new local file in the root images directory
|
||||
if (!newThumbnailFilename) {
|
||||
const videoName = path.parse(path.basename(videoFilePath)).name;
|
||||
newThumbnailFilename = `${videoName}.jpg`;
|
||||
}
|
||||
thumbnailAbsolutePath = path.join(IMAGES_DIR, newThumbnailFilename);
|
||||
newThumbnailPath = `/images/${newThumbnailFilename}`;
|
||||
needsDbUpdate = true;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
fs.ensureDirSync(path.dirname(thumbnailAbsolutePath));
|
||||
|
||||
// Generate thumbnail
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// -y to overwrite existing file
|
||||
exec(`ffmpeg -i "${videoFilePath}" -ss 00:00:00 -vframes 1 "${thumbnailAbsolutePath}" -y`, (error) => {
|
||||
if (error) {
|
||||
console.error("Error generating thumbnail:", error);
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update video record if needed (switching from remote to local, or creating new)
|
||||
if (needsDbUpdate) {
|
||||
const updates: any = {
|
||||
thumbnailFilename: newThumbnailFilename,
|
||||
thumbnailPath: newThumbnailPath,
|
||||
thumbnailUrl: newThumbnailPath
|
||||
};
|
||||
storageService.updateVideo(id, updates);
|
||||
}
|
||||
|
||||
// Return success with timestamp to bust cache
|
||||
const thumbnailUrl = `${newThumbnailPath}?t=${Date.now()}`;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Thumbnail refreshed successfully",
|
||||
thumbnailUrl: thumbnailUrl
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error refreshing thumbnail:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to refresh thumbnail",
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ router.put("/videos/:id", videoController.updateVideoDetails);
|
||||
router.delete("/videos/:id", videoController.deleteVideo);
|
||||
router.get("/videos/:id/comments", videoController.getVideoComments);
|
||||
router.post("/videos/:id/rate", videoController.rateVideo);
|
||||
router.post("/videos/:id/refresh-thumbnail", videoController.refreshThumbnail);
|
||||
|
||||
router.post("/scan-files", scanController.scanFiles);
|
||||
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Video } from '../types';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
import { useSnackbar } from './SnackbarContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -11,6 +12,8 @@ interface VideoContextType {
|
||||
error: string | null;
|
||||
fetchVideos: () => Promise<void>;
|
||||
deleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
updateVideo: (id: string, updates: Partial<Video>) => Promise<{ success: boolean; error?: string }>;
|
||||
refreshThumbnail: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
searchLocalVideos: (query: string) => Video[];
|
||||
searchResults: any[];
|
||||
localSearchResults: Video[];
|
||||
@@ -35,6 +38,7 @@ export const useVideo = () => {
|
||||
|
||||
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { t } = useLanguage();
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -57,7 +61,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
setError(t('failedToLoadVideos'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,12 +73,12 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
await axios.delete(`${API_URL}/videos/${id}`);
|
||||
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
|
||||
setLoading(false);
|
||||
showSnackbar('Video removed successfully');
|
||||
showSnackbar(t('videoRemovedSuccessfully'));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setLoading(false);
|
||||
return { success: false, error: 'Failed to delete video' };
|
||||
return { success: false, error: t('failedToDeleteVideo') };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,7 +106,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const handleSearch = async (query: string): Promise<any> => {
|
||||
if (!query || query.trim() === '') {
|
||||
resetSearch();
|
||||
return { success: false, error: 'Please enter a search term' };
|
||||
return { success: false, error: t('pleaseEnterSearchTerm') };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -151,9 +155,9 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
setSearchTerm(query);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Failed to search. Please try again.' };
|
||||
return { success: false, error: t('failedToSearch') };
|
||||
}
|
||||
return { success: false, error: 'Search was cancelled' };
|
||||
return { success: false, error: t('searchCancelled') };
|
||||
} finally {
|
||||
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
|
||||
setLoading(false);
|
||||
@@ -176,6 +180,42 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshThumbnail = async (id: string) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`);
|
||||
if (response.data.success) {
|
||||
setVideos(prevVideos => prevVideos.map(video =>
|
||||
video.id === id
|
||||
? { ...video, thumbnailUrl: response.data.thumbnailUrl, thumbnailPath: response.data.thumbnailUrl }
|
||||
: video
|
||||
));
|
||||
showSnackbar(t('thumbnailRefreshed'));
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: t('thumbnailRefreshFailed') };
|
||||
} catch (error) {
|
||||
console.error('Error refreshing thumbnail:', error);
|
||||
return { success: false, error: t('thumbnailRefreshFailed') };
|
||||
}
|
||||
};
|
||||
|
||||
const updateVideo = async (id: string, updates: Partial<Video>) => {
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, updates);
|
||||
if (response.data.success) {
|
||||
setVideos(prevVideos => prevVideos.map(video =>
|
||||
video.id === id ? { ...video, ...updates } : video
|
||||
));
|
||||
showSnackbar(t('videoUpdated'));
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: t('videoUpdateFailed') };
|
||||
} catch (error) {
|
||||
console.error('Error updating video:', error);
|
||||
return { success: false, error: t('videoUpdateFailed') };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoContext.Provider value={{
|
||||
videos,
|
||||
@@ -183,6 +223,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
error,
|
||||
fetchVideos,
|
||||
deleteVideo,
|
||||
updateVideo,
|
||||
refreshThumbnail,
|
||||
searchLocalVideos,
|
||||
searchResults,
|
||||
localSearchResults,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
ArrowBack,
|
||||
Check,
|
||||
Close,
|
||||
Delete,
|
||||
Edit,
|
||||
Folder,
|
||||
Refresh,
|
||||
Search,
|
||||
VideoLibrary
|
||||
} from '@mui/icons-material';
|
||||
@@ -30,6 +34,7 @@ import { Link } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
@@ -44,12 +49,19 @@ interface ManagePageProps {
|
||||
const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const { t } = useLanguage();
|
||||
const { refreshThumbnail, updateVideo } = useVideo();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState<Collection | null>(null);
|
||||
const [isDeletingCollection, setIsDeletingCollection] = useState<boolean>(false);
|
||||
const [videoToDelete, setVideoToDelete] = useState<string | null>(null);
|
||||
const [showVideoDeleteModal, setShowVideoDeleteModal] = useState<boolean>(false);
|
||||
|
||||
// Editing state
|
||||
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState<string>('');
|
||||
const [isSavingTitle, setIsSavingTitle] = useState<boolean>(false);
|
||||
|
||||
// Pagination state
|
||||
const [collectionPage, setCollectionPage] = useState(1);
|
||||
const [videoPage, setVideoPage] = useState(1);
|
||||
@@ -117,6 +129,32 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
setCollectionToDelete(null);
|
||||
};
|
||||
|
||||
const handleRefreshThumbnail = async (id: string) => {
|
||||
setRefreshingId(id);
|
||||
await refreshThumbnail(id);
|
||||
setRefreshingId(null);
|
||||
};
|
||||
|
||||
const handleEditClick = (video: Video) => {
|
||||
setEditingVideoId(video.id);
|
||||
setEditTitle(video.title);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingVideoId(null);
|
||||
setEditTitle('');
|
||||
};
|
||||
|
||||
const handleSaveTitle = async (id: string) => {
|
||||
if (!editTitle.trim()) return;
|
||||
|
||||
setIsSavingTitle(true);
|
||||
await updateVideo(id, { title: editTitle });
|
||||
setIsSavingTitle(false);
|
||||
setEditingVideoId(null);
|
||||
setEditTitle('');
|
||||
};
|
||||
|
||||
const getThumbnailSrc = (video: Video) => {
|
||||
if (video.thumbnailPath) {
|
||||
return `${BACKEND_URL}${video.thumbnailPath}`;
|
||||
@@ -259,15 +297,90 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
{displayedVideos.map(video => (
|
||||
<TableRow key={video.id} hover>
|
||||
<TableCell sx={{ width: 140 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={getThumbnailSrc(video)}
|
||||
alt={video.title}
|
||||
sx={{ width: 120, height: 68, objectFit: 'cover', borderRadius: 1 }}
|
||||
/>
|
||||
<Box sx={{ position: 'relative', width: 120, height: 68 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={getThumbnailSrc(video)}
|
||||
alt={video.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
|
||||
/>
|
||||
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRefreshThumbnail(video.id)}
|
||||
disabled={refreshingId === video.id}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
p: 0.5,
|
||||
width: 24,
|
||||
height: 24
|
||||
}}
|
||||
>
|
||||
{refreshingId === video.id ? <CircularProgress size={14} color="inherit" /> : <Refresh sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 500 }}>
|
||||
{video.title}
|
||||
<TableCell sx={{ fontWeight: 500, maxWidth: 400 }}>
|
||||
{editingVideoId === video.id ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<TextField
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveTitle(video.id);
|
||||
if (e.key === 'Escape') handleCancelEdit();
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleSaveTitle(video.id)}
|
||||
disabled={isSavingTitle}
|
||||
>
|
||||
{isSavingTitle ? <CircularProgress size={20} /> : <Check />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={isSavingTitle}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditClick(video)}
|
||||
sx={{ mr: 1, mt: -0.5, opacity: 0.6, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
{video.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{video.author}</TableCell>
|
||||
<TableCell align="right">
|
||||
|
||||
@@ -140,6 +140,17 @@ export const ar = {
|
||||
editTitle: "تعديل العنوان",
|
||||
titleUpdated: "تم تحديث العنوان بنجاح",
|
||||
titleUpdateFailed: "فشل تحديث العنوان",
|
||||
refreshThumbnail: "تحديث الصورة المصغرة",
|
||||
thumbnailRefreshed: "تم تحديث الصورة المصغرة بنجاح",
|
||||
thumbnailRefreshFailed: "فشل تحديث الصورة المصغرة",
|
||||
videoUpdated: "تم تحديث الفيديو بنجاح",
|
||||
videoUpdateFailed: "فشل تحديث الفيديو",
|
||||
failedToLoadVideos: "فشل تحميل مقاطع الفيديو. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
videoRemovedSuccessfully: "تم حذف الفيديو بنجاح",
|
||||
failedToDeleteVideo: "فشل حذف الفيديو",
|
||||
pleaseEnterSearchTerm: "الرجاء إدخال مصطلح البحث",
|
||||
failedToSearch: "فشل البحث. يرجى المحاولة مرة أخرى.",
|
||||
searchCancelled: "تم إلغاء البحث",
|
||||
|
||||
// Login
|
||||
signIn: "تسجيل الدخول",
|
||||
|
||||
@@ -26,7 +26,9 @@ export const de = {
|
||||
migrateConfirmation: "Sind Sie sicher, dass Sie Daten migrieren möchten? Dies kann einige Momente dauern.",
|
||||
migrationResults: "Migrationsergebnisse", migrationReport: "Migrationsbericht",
|
||||
migrationSuccess: "Migration abgeschlossen. Details in der Warnung anzeigen.", migrationNoData: "Migration abgeschlossen, aber keine Daten gefunden.",
|
||||
migrationFailed: "Migration fehlgeschlagen", migrationWarnings: "WARNUNGEN", migrationErrors: "FEHLER",
|
||||
migrationFailed: "Migration fehlgeschlagen",
|
||||
|
||||
migrationWarnings: "WARNUNGEN", migrationErrors: "FEHLER",
|
||||
itemsMigrated: "Elemente migriert", fileNotFound: "Datei nicht gefunden unter",
|
||||
noDataFilesFound: "Keine Datendateien zum Migrieren gefunden. Bitte überprüfen Sie Ihre Volume-Zuordnungen.",
|
||||
removeLegacyData: "Legacy-Daten Entfernen", removeLegacyDataDescription: "Löschen Sie die alten JSON-Dateien, um Speicherplatz freizugeben. Tun Sie dies nur, nachdem Sie überprüft haben, dass Ihre Daten erfolgreich migriert wurden.",
|
||||
@@ -52,6 +54,17 @@ export const de = {
|
||||
loadingVideo: "Video wird geladen...", current: "(Aktuell)", rateThisVideo: "Dieses Video bewerten",
|
||||
enterFullscreen: "Vollbild", exitFullscreen: "Vollbild Verlassen", editTitle: "Titel Bearbeiten",
|
||||
titleUpdated: "Titel erfolgreich aktualisiert", titleUpdateFailed: "Fehler beim Aktualisieren des Titels",
|
||||
refreshThumbnail: "Vorschaubild aktualisieren",
|
||||
thumbnailRefreshed: "Vorschaubild erfolgreich aktualisiert",
|
||||
thumbnailRefreshFailed: "Aktualisierung des Vorschaubilds fehlgeschlagen",
|
||||
videoUpdated: "Video erfolgreich aktualisiert",
|
||||
videoUpdateFailed: "Videoaktualisierung fehlgeschlagen",
|
||||
failedToLoadVideos: "Videos konnten nicht geladen werden. Bitte versuchen Sie es später erneut.",
|
||||
videoRemovedSuccessfully: "Video erfolgreich entfernt",
|
||||
failedToDeleteVideo: "Löschen des Videos fehlgeschlagen",
|
||||
pleaseEnterSearchTerm: "Bitte geben Sie einen Suchbegriff ein",
|
||||
failedToSearch: "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
searchCancelled: "Suche abgebrochen",
|
||||
signIn: "Anmelden", verifying: "Überprüfen...", incorrectPassword: "Falsches Passwort",
|
||||
loginFailed: "Fehler beim Überprüfen des Passworts", defaultPasswordHint: "Standardpasswort: 123",
|
||||
loadingCollection: "Sammlung wird geladen...", collectionNotFound: "Sammlung nicht gefunden",
|
||||
|
||||
@@ -101,6 +101,7 @@ export const en = {
|
||||
deleteCollection: "Delete Collection",
|
||||
deleteVideo: "Delete Video",
|
||||
noVideosFoundMatching: "No videos found matching your search.",
|
||||
refreshThumbnail: "Refresh Thumbnail",
|
||||
|
||||
// Video Player
|
||||
playing: "Play",
|
||||
@@ -140,6 +141,16 @@ export const en = {
|
||||
editTitle: "Edit Title",
|
||||
titleUpdated: "Title updated successfully",
|
||||
titleUpdateFailed: "Failed to update title",
|
||||
thumbnailRefreshed: "Thumbnail refreshed successfully",
|
||||
thumbnailRefreshFailed: "Failed to refresh thumbnail",
|
||||
videoUpdated: "Video updated successfully",
|
||||
videoUpdateFailed: "Failed to update video",
|
||||
failedToLoadVideos: "Failed to load videos. Please try again later.",
|
||||
videoRemovedSuccessfully: "Video removed successfully",
|
||||
failedToDeleteVideo: "Failed to delete video",
|
||||
pleaseEnterSearchTerm: "Please enter a search term",
|
||||
failedToSearch: "Failed to search. Please try again.",
|
||||
searchCancelled: "Search was cancelled",
|
||||
|
||||
// Login
|
||||
signIn: "Sign in",
|
||||
|
||||
@@ -51,7 +51,18 @@ export const es = {
|
||||
confirmRemoveFromCollection: "¿Está seguro de que desea eliminar este video de la colección?", remove: "Eliminar",
|
||||
loadingVideo: "Cargando video...", current: "(Actual)", rateThisVideo: "Calificar este video",
|
||||
enterFullscreen: "Pantalla Completa", exitFullscreen: "Salir de Pantalla Completa", editTitle: "Editar Título",
|
||||
titleUpdated: "Título actualizado exitosamente", titleUpdateFailed: "Error al actualizar el título",
|
||||
titleUpdated: "Título actualizado exitosamente", titleUpdateFailed: "Error al actualizar el título",
|
||||
refreshThumbnail: "Actualizar miniatura",
|
||||
thumbnailRefreshed: "Miniatura actualizada con éxito",
|
||||
thumbnailRefreshFailed: "Error al actualizar la miniatura",
|
||||
videoUpdated: "Video actualizado con éxito",
|
||||
videoUpdateFailed: "Error al actualizar el video",
|
||||
failedToLoadVideos: "Error al cargar videos. Por favor, inténtelo de nuevo más tarde.",
|
||||
videoRemovedSuccessfully: "Video eliminado con éxito",
|
||||
failedToDeleteVideo: "Error al eliminar el video",
|
||||
pleaseEnterSearchTerm: "Por favor, introduzca un término de búsqueda",
|
||||
failedToSearch: "Error en la búsqueda. Por favor, inténtelo de nuevo.",
|
||||
searchCancelled: "Búsqueda cancelada",
|
||||
signIn: "Iniciar Sesión", verifying: "Verificando...", incorrectPassword: "Contraseña incorrecta",
|
||||
loginFailed: "Error al verificar la contraseña", defaultPasswordHint: "Contraseña predeterminada: 123",
|
||||
loadingCollection: "Cargando colección...", collectionNotFound: "Colección no encontrada",
|
||||
|
||||
@@ -101,6 +101,7 @@ export const fr = {
|
||||
deleteCollection: "Supprimer la collection",
|
||||
deleteVideo: "Supprimer la vidéo",
|
||||
noVideosFoundMatching: "Aucune vidéo ne correspond à votre recherche.",
|
||||
refreshThumbnail: "Actualiser la miniature",
|
||||
|
||||
// Video Player
|
||||
playing: "Lecture",
|
||||
@@ -140,6 +141,16 @@ export const fr = {
|
||||
editTitle: "Modifier le titre",
|
||||
titleUpdated: "Titre mis à jour avec succès",
|
||||
titleUpdateFailed: "Échec de la mise à jour du titre",
|
||||
thumbnailRefreshed: "Miniature actualisée avec succès",
|
||||
thumbnailRefreshFailed: "Échec de l'actualisation de la miniature",
|
||||
videoUpdated: "Vidéo mise à jour avec succès",
|
||||
videoUpdateFailed: "Échec de la mise à jour de la vidéo",
|
||||
failedToLoadVideos: "Échec du chargement des vidéos. Veuillez réessayer plus tard.",
|
||||
videoRemovedSuccessfully: "Vidéo supprimée avec succès",
|
||||
failedToDeleteVideo: "Échec de la suppression de la vidéo",
|
||||
pleaseEnterSearchTerm: "Veuillez entrer un terme de recherche",
|
||||
failedToSearch: "Échec de la recherche. Veuillez réessayer.",
|
||||
searchCancelled: "Recherche annulée",
|
||||
|
||||
// Login
|
||||
signIn: "Se connecter",
|
||||
|
||||
@@ -140,6 +140,17 @@ export const ja = {
|
||||
editTitle: "タイトルを編集",
|
||||
titleUpdated: "タイトルが正常に更新されました",
|
||||
titleUpdateFailed: "タイトルの更新に失敗しました",
|
||||
refreshThumbnail: "サムネイルを更新",
|
||||
thumbnailRefreshed: "サムネイルを更新しました",
|
||||
thumbnailRefreshFailed: "サムネイルの更新に失敗しました",
|
||||
videoUpdated: "動画を更新しました",
|
||||
videoUpdateFailed: "動画の更新に失敗しました",
|
||||
failedToLoadVideos: "動画の読み込みに失敗しました。後でもう一度お試しください。",
|
||||
videoRemovedSuccessfully: "動画を削除しました",
|
||||
failedToDeleteVideo: "動画の削除に失敗しました",
|
||||
pleaseEnterSearchTerm: "検索語を入力してください",
|
||||
failedToSearch: "検索に失敗しました。もう一度お試しください。",
|
||||
searchCancelled: "検索がキャンセルされました",
|
||||
|
||||
// Login
|
||||
signIn: "サインイン",
|
||||
|
||||
@@ -140,6 +140,17 @@ export const ko = {
|
||||
editTitle: "제목 편집",
|
||||
titleUpdated: "제목이 성공적으로 업데이트됨",
|
||||
titleUpdateFailed: "제목 업데이트 실패",
|
||||
refreshThumbnail: "썸네일 새로고침",
|
||||
thumbnailRefreshed: "썸네일이 새로고침되었습니다",
|
||||
thumbnailRefreshFailed: "썸네일 새로고침 실패",
|
||||
videoUpdated: "비디오가 업데이트되었습니다",
|
||||
videoUpdateFailed: "비디오 업데이트 실패",
|
||||
failedToLoadVideos: "비디오를 불러오지 못했습니다. 나중에 다시 시도해주세요.",
|
||||
videoRemovedSuccessfully: "비디오가 삭제되었습니다",
|
||||
failedToDeleteVideo: "비디오 삭제 실패",
|
||||
pleaseEnterSearchTerm: "검색어를 입력해주세요",
|
||||
failedToSearch: "검색 실패. 다시 시도해주세요.",
|
||||
searchCancelled: "검색이 취소되었습니다",
|
||||
|
||||
// Login
|
||||
signIn: "로그인",
|
||||
|
||||
@@ -140,6 +140,17 @@ export const pt = {
|
||||
editTitle: "Editar Título",
|
||||
titleUpdated: "Título atualizado com sucesso",
|
||||
titleUpdateFailed: "Falha ao atualizar título",
|
||||
refreshThumbnail: "Atualizar miniatura",
|
||||
thumbnailRefreshed: "Miniatura atualizada com sucesso",
|
||||
thumbnailRefreshFailed: "Falha ao atualizar miniatura",
|
||||
videoUpdated: "Vídeo atualizado com sucesso",
|
||||
videoUpdateFailed: "Falha ao atualizar vídeo",
|
||||
failedToLoadVideos: "Falha ao carregar vídeos. Por favor, tente novamente mais tarde.",
|
||||
videoRemovedSuccessfully: "Vídeo removido com sucesso",
|
||||
failedToDeleteVideo: "Falha ao remover vídeo",
|
||||
pleaseEnterSearchTerm: "Por favor, insira um termo de pesquisa",
|
||||
failedToSearch: "Falha na pesquisa. Por favor, tente novamente.",
|
||||
searchCancelled: "Pesquisa cancelada",
|
||||
|
||||
// Login
|
||||
signIn: "Entrar",
|
||||
|
||||
@@ -140,6 +140,17 @@ export const ru = {
|
||||
editTitle: "Редактировать название",
|
||||
titleUpdated: "Название успешно обновлено",
|
||||
titleUpdateFailed: "Не удалось обновить название",
|
||||
refreshThumbnail: "Обновить миниатюру",
|
||||
thumbnailRefreshed: "Миниатюра успешно обновлена",
|
||||
thumbnailRefreshFailed: "Не удалось обновить миниатюру",
|
||||
videoUpdated: "Видео успешно обновлено",
|
||||
videoUpdateFailed: "Не удалось обновить видео",
|
||||
failedToLoadVideos: "Не удалось загрузить видео. Пожалуйста, попробуйте позже.",
|
||||
videoRemovedSuccessfully: "Видео успешно удалено",
|
||||
failedToDeleteVideo: "Не удалось удалить видео",
|
||||
pleaseEnterSearchTerm: "Пожалуйста, введите поисковый запрос",
|
||||
failedToSearch: "Поиск не удался. Пожалуйста, попробуйте снова.",
|
||||
searchCancelled: "Поиск отменен",
|
||||
|
||||
// Login
|
||||
signIn: "Войти",
|
||||
|
||||
@@ -140,6 +140,17 @@ export const zh = {
|
||||
editTitle: "编辑标题",
|
||||
titleUpdated: "标题更新成功",
|
||||
titleUpdateFailed: "更新标题失败",
|
||||
refreshThumbnail: "刷新缩略图",
|
||||
thumbnailRefreshed: "缩略图刷新成功",
|
||||
thumbnailRefreshFailed: "刷新缩略图失败",
|
||||
videoUpdated: "视频更新成功",
|
||||
videoUpdateFailed: "更新视频失败",
|
||||
failedToLoadVideos: "加载视频失败。请稍后再试。",
|
||||
videoRemovedSuccessfully: "视频删除成功",
|
||||
failedToDeleteVideo: "删除视频失败",
|
||||
pleaseEnterSearchTerm: "请输入搜索词",
|
||||
failedToSearch: "搜索失败。请稍后再试。",
|
||||
searchCancelled: "搜索已取消",
|
||||
|
||||
// Login
|
||||
signIn: "登录",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
Reference in New Issue
Block a user