feat(frontend): enable title editing in VideoPlayer
This commit is contained in:
155
backend/src/controllers/scanController.ts
Normal file
155
backend/src/controllers/scanController.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { exec } from "child_process";
|
||||
import { Request, Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
// Recursive function to get all files in a directory
|
||||
const getFilesRecursively = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
|
||||
list.forEach((file) => {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(getFilesRecursively(filePath));
|
||||
} else {
|
||||
results.push(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
console.log("Starting file scan...");
|
||||
|
||||
// 1. Get all existing videos from DB
|
||||
const existingVideos = storageService.getVideos();
|
||||
const existingPaths = new Set<string>();
|
||||
const existingFilenames = new Set<string>();
|
||||
|
||||
existingVideos.forEach(v => {
|
||||
if (v.videoPath) existingPaths.add(v.videoPath);
|
||||
if (v.videoFilename) existingFilenames.add(v.videoFilename);
|
||||
});
|
||||
|
||||
// 2. Recursively scan VIDEOS_DIR
|
||||
if (!fs.existsSync(VIDEOS_DIR)) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Videos directory does not exist",
|
||||
addedCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
const allFiles = getFilesRecursively(VIDEOS_DIR);
|
||||
const videoExtensions = ['.mp4', '.mkv', '.webm', '.avi', '.mov'];
|
||||
let addedCount = 0;
|
||||
|
||||
// 3. Process each file
|
||||
for (const filePath of allFiles) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!videoExtensions.includes(ext)) continue;
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
const relativePath = path.relative(VIDEOS_DIR, filePath);
|
||||
// Construct the web-accessible path (assuming /videos maps to VIDEOS_DIR)
|
||||
// If the file is in a subdirectory, relativePath will be "subdir/file.mp4"
|
||||
// We need to ensure we use forward slashes for URLs
|
||||
const webPath = `/videos/${relativePath.split(path.sep).join('/')}`;
|
||||
|
||||
// Check if exists
|
||||
// We check both filename (for flat structure compatibility) and full web path
|
||||
if (existingFilenames.has(filename)) continue;
|
||||
|
||||
// Also check if we already have this specific path (in case of duplicate filenames in diff folders)
|
||||
// But for now, let's assume filename uniqueness is preferred or at least check it.
|
||||
// Actually, if we have "folder1/a.mp4" and "folder2/a.mp4", they are different videos.
|
||||
// But existing logic often relies on filename.
|
||||
// Let's check if there is ANY video with this filename.
|
||||
// If the user wants to support duplicate filenames in different folders, we might need to relax this.
|
||||
// For now, let's stick to the plan: check if it exists in DB.
|
||||
|
||||
// Refined check:
|
||||
// If we find a file that is NOT in the DB, we add it.
|
||||
// We use the filename to check against existing records because `videoFilename` is often used as a key.
|
||||
|
||||
console.log(`Found new video file: ${relativePath}`);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const createdDate = stats.birthtime;
|
||||
const videoId = (Date.now() + Math.floor(Math.random() * 10000)).toString();
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailFilename = `${path.parse(filename).name}.jpg`;
|
||||
// If video is in subdir, put thumbnail in same subdir structure in IMAGES_DIR?
|
||||
// Or just flat in IMAGES_DIR?
|
||||
// videoController puts it in IMAGES_DIR flatly.
|
||||
// But if we have subdirs, we might have name collisions.
|
||||
// For now, let's follow videoController pattern: flat IMAGES_DIR.
|
||||
// Wait, videoController uses uniqueSuffix for filename, so no collision.
|
||||
// Here we use original filename.
|
||||
// Let's try to mirror the structure if possible, or just use flat for now as per simple req.
|
||||
// The user said "scan /uploads/videos structure".
|
||||
// If I have videos/foo/bar.mp4, maybe I should put thumbnail in images/foo/bar.jpg?
|
||||
// But IMAGES_DIR is a single path.
|
||||
// Let's stick to flat IMAGES_DIR for simplicity, but maybe prepend subdir name to filename to avoid collision?
|
||||
// Or just use the simple name as per request "take first frame as thumbnail".
|
||||
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
// We need to await this, so we can't use forEach efficiently if we want to be async inside.
|
||||
// We are in a for..of loop, so await is fine.
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
exec(`ffmpeg -i "${filePath}" -ss 00:00:00 -vframes 1 "${thumbnailPath}"`, (error) => {
|
||||
if (error) {
|
||||
console.error("Error generating thumbnail:", error);
|
||||
resolve();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
title: path.parse(filename).name,
|
||||
author: "Admin",
|
||||
source: "local",
|
||||
sourceUrl: "",
|
||||
videoFilename: filename,
|
||||
videoPath: webPath,
|
||||
thumbnailFilename: fs.existsSync(thumbnailPath) ? thumbnailFilename : undefined,
|
||||
thumbnailPath: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
thumbnailUrl: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
createdAt: createdDate.toISOString(),
|
||||
addedAt: new Date().toISOString(),
|
||||
date: createdDate.toISOString().split('T')[0].replace(/-/g, ''),
|
||||
};
|
||||
|
||||
storageService.saveVideo(newVideo);
|
||||
addedCount++;
|
||||
}
|
||||
|
||||
console.log(`Scan complete. Added ${addedCount} new videos.`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `Scan complete. Added ${addedCount} new videos.`,
|
||||
addedCount
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error scanning files:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to scan files",
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -496,3 +496,35 @@ export const rateVideo = (req: Request, res: Response): any => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update video details
|
||||
export const updateVideoDetails = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
// Filter allowed updates
|
||||
const allowedUpdates: any = {};
|
||||
if (updates.title !== undefined) allowedUpdates.title = updates.title;
|
||||
// Add other allowed fields here if needed in the future
|
||||
|
||||
if (Object.keys(allowedUpdates).length === 0) {
|
||||
return res.status(400).json({ error: "No valid updates provided" });
|
||||
}
|
||||
|
||||
const updatedVideo = storageService.updateVideo(id, allowedUpdates);
|
||||
|
||||
if (!updatedVideo) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Video updated successfully",
|
||||
video: updatedVideo
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating video:", error);
|
||||
res.status(500).json({ error: "Failed to update video" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,5 +19,6 @@ export function runMigrations() {
|
||||
// Don't throw, as we might want the app to start even if migration fails (though it might be broken)
|
||||
// But for initial setup, it's critical.
|
||||
throw error;
|
||||
// console.warn("Migration failed but continuing server startup...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from "express";
|
||||
import * as collectionController from "../controllers/collectionController";
|
||||
import * as scanController from "../controllers/scanController";
|
||||
import * as videoController from "../controllers/videoController";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -10,10 +11,13 @@ router.post("/download", videoController.downloadVideo);
|
||||
router.post("/upload", videoController.upload.single("video"), videoController.uploadVideo);
|
||||
router.get("/videos", videoController.getVideos);
|
||||
router.get("/videos/:id", videoController.getVideoById);
|
||||
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("/scan-files", scanController.scanFiles);
|
||||
|
||||
router.get("/download-status", videoController.getDownloadStatus);
|
||||
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
|
||||
router.get("/check-bilibili-collection", videoController.checkBilibiliCollection);
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ConfirmationModalProps {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDanger?: boolean;
|
||||
showCancel?: boolean;
|
||||
}
|
||||
|
||||
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
@@ -30,7 +31,8 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
isDanger = false
|
||||
isDanger = false,
|
||||
showCancel = true
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
@@ -62,14 +64,16 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
<DialogContentText id="alert-dialog-description" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{message}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onClose} color="inherit" variant="text">
|
||||
{cancelText}
|
||||
</Button>
|
||||
{showCancel && (
|
||||
<Button onClick={onClose} color="inherit" variant="text">
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
|
||||
@@ -261,6 +261,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
title={t('deleteVideo')}
|
||||
message={`${t('confirmDelete')} "${video.title}"?`}
|
||||
confirmText={t('delete')}
|
||||
cancelText={t('cancel')}
|
||||
isDanger={true}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,11 @@ import VERSION from './version';
|
||||
|
||||
import { SnackbarProvider } from './contexts/SnackbarContext';
|
||||
|
||||
import ConsoleManager from './utils/consoleManager';
|
||||
|
||||
// Initialize console manager
|
||||
ConsoleManager.init();
|
||||
|
||||
// Display version information
|
||||
VERSION.displayVersion();
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
title={t('deleteVideo')}
|
||||
message={t('confirmDelete')}
|
||||
confirmText={t('delete')}
|
||||
cancelText={t('cancel')}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import ConsoleManager from '../utils/consoleManager';
|
||||
import { Language } from '../utils/translations';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -52,7 +53,19 @@ const SettingsPage: React.FC = () => {
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [showDeleteLegacyModal, setShowDeleteLegacyModal] = useState(false);
|
||||
const [showMigrateConfirmModal, setShowMigrateConfirmModal] = useState(false);
|
||||
const [infoModal, setInfoModal] = useState<{ isOpen: boolean; title: string; message: string; type: 'success' | 'error' | 'info' | 'warning' }>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'info'
|
||||
});
|
||||
|
||||
const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode());
|
||||
|
||||
const { t, setLanguage } = useLanguage();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -231,60 +244,45 @@ const SettingsPage: React.FC = () => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={async () => {
|
||||
if (window.confirm(t('migrateConfirmation'))) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await axios.post(`${API_URL}/settings/migrate`);
|
||||
const results = res.data.results;
|
||||
console.log('Migration results:', results);
|
||||
|
||||
let msg = `${t('migrationReport')}:\n`;
|
||||
let hasData = false;
|
||||
|
||||
if (results.warnings && results.warnings.length > 0) {
|
||||
msg += `\n⚠️ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const categories = ['videos', 'collections', 'settings', 'downloads'];
|
||||
categories.forEach(cat => {
|
||||
const data = results[cat];
|
||||
if (data) {
|
||||
if (data.found) {
|
||||
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
|
||||
hasData = true;
|
||||
} else {
|
||||
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (results.errors && results.errors.length > 0) {
|
||||
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
|
||||
}
|
||||
|
||||
if (!hasData && (!results.errors || results.errors.length === 0)) {
|
||||
msg += `\n\n⚠️ ${t('noDataFilesFound')}`;
|
||||
}
|
||||
|
||||
alert(msg);
|
||||
setMessage({ text: hasData ? t('migrationSuccess') : t('migrationNoData'), type: hasData ? 'success' : 'warning' });
|
||||
} catch (error: any) {
|
||||
console.error('Migration failed:', error);
|
||||
setMessage({
|
||||
text: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowMigrateConfirmModal(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('migrateDataButton')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await axios.post(`${API_URL}/scan-files`);
|
||||
const { addedCount } = res.data;
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: t('scanFilesSuccess').replace('{count}', addedCount.toString()),
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Scan failed:', error);
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
{t('scanFiles')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>{t('removeLegacyData')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
@@ -301,6 +299,28 @@ const SettingsPage: React.FC = () => {
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>{t('debugMode')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{t('debugModeDescription')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={debugMode}
|
||||
onChange={(e) => {
|
||||
setDebugMode(e.target.checked);
|
||||
ConsoleManager.setDebugMode(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={t('debugMode')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button
|
||||
@@ -346,12 +366,22 @@ const SettingsPage: React.FC = () => {
|
||||
msg += `\nFailed: ${results.failed.join(', ')}`;
|
||||
}
|
||||
|
||||
alert(msg);
|
||||
setMessage({ text: t('legacyDataDeleted'), type: 'success' });
|
||||
if (results.failed.length > 0) {
|
||||
msg += `\nFailed: ${results.failed.join(', ')}`;
|
||||
}
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: msg,
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete legacy data:', error);
|
||||
setMessage({
|
||||
text: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
@@ -361,8 +391,84 @@ const SettingsPage: React.FC = () => {
|
||||
title={t('removeLegacyDataConfirmTitle')}
|
||||
message={t('removeLegacyDataConfirmMessage')}
|
||||
confirmText={t('delete')}
|
||||
cancelText={t('cancel')}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
{/* Migrate Data Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
isOpen={showMigrateConfirmModal}
|
||||
onClose={() => setShowMigrateConfirmModal(false)}
|
||||
onConfirm={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await axios.post(`${API_URL}/settings/migrate`);
|
||||
const results = res.data.results;
|
||||
console.log('Migration results:', results);
|
||||
|
||||
let msg = `${t('migrationReport')}:\n`;
|
||||
let hasData = false;
|
||||
|
||||
if (results.warnings && results.warnings.length > 0) {
|
||||
msg += `\n⚠️ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const categories = ['videos', 'collections', 'settings', 'downloads'];
|
||||
categories.forEach(cat => {
|
||||
const data = results[cat];
|
||||
if (data) {
|
||||
if (data.found) {
|
||||
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
|
||||
hasData = true;
|
||||
} else {
|
||||
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (results.errors && results.errors.length > 0) {
|
||||
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
|
||||
}
|
||||
|
||||
if (!hasData && (!results.errors || results.errors.length === 0)) {
|
||||
msg += `\n\n⚠️ ${t('noDataFilesFound')}`;
|
||||
}
|
||||
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: hasData ? t('migrationResults') : t('migrationNoData'),
|
||||
message: msg,
|
||||
type: hasData ? 'success' : 'warning'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Migration failed:', error);
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
title={t('migrateDataButton')}
|
||||
message={t('migrateConfirmation')}
|
||||
confirmText={t('confirm')}
|
||||
cancelText={t('cancel')}
|
||||
/>
|
||||
|
||||
{/* Info/Result Modal */}
|
||||
<ConfirmationModal
|
||||
isOpen={infoModal.isOpen}
|
||||
onClose={() => setInfoModal(prev => ({ ...prev, isOpen: false }))}
|
||||
onConfirm={() => setInfoModal(prev => ({ ...prev, isOpen: false }))}
|
||||
title={infoModal.title}
|
||||
message={infoModal.message}
|
||||
confirmText="OK"
|
||||
showCancel={false}
|
||||
isDanger={infoModal.type === 'error'}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
Add,
|
||||
CalendarToday,
|
||||
Check,
|
||||
Close,
|
||||
Delete,
|
||||
Download,
|
||||
Edit,
|
||||
FastForward,
|
||||
FastRewind,
|
||||
Folder,
|
||||
@@ -50,6 +53,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { Collection, Comment, Video } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -77,6 +81,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -96,6 +101,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [isLooping, setIsLooping] = useState<boolean>(false);
|
||||
|
||||
// Title editing state
|
||||
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
|
||||
const [editedTitle, setEditedTitle] = useState<string>('');
|
||||
|
||||
// Confirmation Modal State
|
||||
const [confirmationModal, setConfirmationModal] = useState({
|
||||
isOpen: false,
|
||||
@@ -103,6 +112,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
message: '',
|
||||
onConfirm: () => { },
|
||||
confirmText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
isDanger: false
|
||||
});
|
||||
|
||||
@@ -310,6 +320,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
message: t('confirmDelete'),
|
||||
onConfirm: executeDelete,
|
||||
confirmText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
isDanger: true
|
||||
});
|
||||
};
|
||||
@@ -368,11 +379,12 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
message: t('confirmRemoveFromCollection'),
|
||||
onConfirm: executeRemoveFromCollection,
|
||||
confirmText: t('remove'),
|
||||
cancelText: t('cancel'),
|
||||
isDanger: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleRatingChange = async (event: React.SyntheticEvent, newValue: number | null) => {
|
||||
const handleRatingChange = async (_: React.SyntheticEvent, newValue: number | null) => {
|
||||
if (!newValue || !id) return;
|
||||
|
||||
try {
|
||||
@@ -383,6 +395,34 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEditingTitle = () => {
|
||||
if (video) {
|
||||
setEditedTitle(video.title);
|
||||
setIsEditingTitle(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEditingTitle = () => {
|
||||
setIsEditingTitle(false);
|
||||
setEditedTitle('');
|
||||
};
|
||||
|
||||
const handleSaveTitle = async () => {
|
||||
if (!id || !editedTitle.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, { title: editedTitle });
|
||||
if (response.data.success) {
|
||||
setVideo(prev => prev ? { ...prev, title: editedTitle } : null);
|
||||
setIsEditingTitle(false);
|
||||
showSnackbar(t('titleUpdated'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating title:', error);
|
||||
showSnackbar(t('titleUpdateFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
@@ -498,9 +538,54 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
</Box>
|
||||
{/* Info Column */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="h5" component="h1" fontWeight="bold" gutterBottom>
|
||||
{video.title}
|
||||
</Typography>
|
||||
{isEditingTitle ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoFocus
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveTitle();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSaveTitle}
|
||||
sx={{ minWidth: 'auto', p: 0.5 }}
|
||||
>
|
||||
<Check />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleCancelEditingTitle}
|
||||
sx={{ minWidth: 'auto', p: 0.5 }}
|
||||
>
|
||||
<Close />
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h5" component="h1" fontWeight="bold" sx={{ mr: 1 }}>
|
||||
{video.title}
|
||||
</Typography>
|
||||
<Tooltip title={t('editTitle')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleStartEditingTitle}
|
||||
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Rating
|
||||
@@ -813,6 +898,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
title={confirmationModal.title}
|
||||
message={confirmationModal.message}
|
||||
confirmText={confirmationModal.confirmText}
|
||||
cancelText={confirmationModal.cancelText}
|
||||
isDanger={confirmationModal.isDanger}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
89
frontend/src/utils/consoleManager.ts
Normal file
89
frontend/src/utils/consoleManager.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
type ConsoleMethod = (...args: any[]) => void;
|
||||
|
||||
interface ConsoleMethods {
|
||||
log: ConsoleMethod;
|
||||
info: ConsoleMethod;
|
||||
warn: ConsoleMethod;
|
||||
error: ConsoleMethod;
|
||||
debug: ConsoleMethod;
|
||||
}
|
||||
|
||||
class ConsoleManager {
|
||||
private static originalConsole: ConsoleMethods | null = null;
|
||||
private static isDebugMode: boolean = false;
|
||||
private static readonly STORAGE_KEY = 'mytube_debug_mode';
|
||||
|
||||
static init() {
|
||||
// Save original methods
|
||||
this.originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug
|
||||
};
|
||||
|
||||
// Load saved preference
|
||||
const savedMode = localStorage.getItem(this.STORAGE_KEY);
|
||||
// Default to true (showing logs) if not set, or parse the value
|
||||
// If the user wants to HIDE logs by default, they can toggle it.
|
||||
// But usually "Debug Mode" means SHOWING logs.
|
||||
// Wait, the request says "toggle debug mode, that will show/hide all console messages".
|
||||
// Usually apps have logs visible by default in dev, but maybe in prod they want them hidden?
|
||||
// Or maybe the user wants to hide them to clean up the UI?
|
||||
// Let's assume "Debug Mode" = "Show Logs".
|
||||
// If Debug Mode is OFF, we hide logs.
|
||||
|
||||
// However, standard behavior is logs are visible.
|
||||
// So "Debug Mode" might mean "Verbose Logging" or just "Enable Console".
|
||||
// Let's stick to:
|
||||
// Debug Mode ON = Console works as normal.
|
||||
// Debug Mode OFF = Console is silenced.
|
||||
|
||||
// Let's default to ON (logs visible) so we don't confuse new users/devs.
|
||||
const isDebug = savedMode === null ? true : savedMode === 'true';
|
||||
this.setDebugMode(isDebug);
|
||||
}
|
||||
|
||||
static setDebugMode(enabled: boolean) {
|
||||
this.isDebugMode = enabled;
|
||||
localStorage.setItem(this.STORAGE_KEY, String(enabled));
|
||||
|
||||
if (enabled) {
|
||||
this.restoreConsole();
|
||||
console.log('Debug mode enabled');
|
||||
} else {
|
||||
console.log('Debug mode disabled');
|
||||
this.suppressConsole();
|
||||
}
|
||||
}
|
||||
|
||||
static getDebugMode(): boolean {
|
||||
return this.isDebugMode;
|
||||
}
|
||||
|
||||
private static suppressConsole() {
|
||||
if (!this.originalConsole) return;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
console.log = noop;
|
||||
console.info = noop;
|
||||
console.warn = noop;
|
||||
console.error = noop;
|
||||
console.debug = noop;
|
||||
}
|
||||
|
||||
private static restoreConsole() {
|
||||
if (!this.originalConsole) return;
|
||||
|
||||
console.log = this.originalConsole.log;
|
||||
console.info = this.originalConsole.info;
|
||||
console.warn = this.originalConsole.warn;
|
||||
console.error = this.originalConsole.error;
|
||||
console.debug = this.originalConsole.debug;
|
||||
}
|
||||
}
|
||||
|
||||
export default ConsoleManager;
|
||||
@@ -47,11 +47,16 @@ export const translations = {
|
||||
backToManage: "Back to Manage",
|
||||
settingsSaved: "Settings saved successfully",
|
||||
settingsFailed: "Failed to save settings",
|
||||
debugMode: "Debug Mode",
|
||||
debugModeDescription: "Show or hide console messages (requires refresh)",
|
||||
|
||||
// Database
|
||||
database: "Database",
|
||||
migrateDataDescription: "Migrate data from legacy JSON files to the new SQLite database. This action is safe to run multiple times (duplicates will be skipped).",
|
||||
migrateDataButton: "Migrate Data from JSON",
|
||||
scanFiles: "Scan Files",
|
||||
scanFilesSuccess: "Scan complete. Added {count} new videos.",
|
||||
scanFilesFailed: "Scan failed",
|
||||
migrateConfirmation: "Are you sure you want to migrate data? This may take a few moments.",
|
||||
migrationResults: "Migration Results",
|
||||
migrationReport: "Migration Report",
|
||||
@@ -128,6 +133,9 @@ export const translations = {
|
||||
rateThisVideo: "Rate this video",
|
||||
enterFullscreen: "Enter Fullscreen",
|
||||
exitFullscreen: "Exit Fullscreen",
|
||||
editTitle: "Edit Title",
|
||||
titleUpdated: "Title updated successfully",
|
||||
titleUpdateFailed: "Failed to update title",
|
||||
|
||||
// Login
|
||||
signIn: "Sign in",
|
||||
@@ -160,6 +168,7 @@ export const translations = {
|
||||
success: "Success",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
save: "Save",
|
||||
on: "On",
|
||||
off: "Off",
|
||||
|
||||
@@ -243,11 +252,16 @@ export const translations = {
|
||||
backToManage: "返回管理",
|
||||
settingsSaved: "设置保存成功",
|
||||
settingsFailed: "保存设置失败",
|
||||
debugMode: "调试模式",
|
||||
debugModeDescription: "显示或隐藏控制台消息(需要刷新)",
|
||||
|
||||
// Database
|
||||
database: "数据库",
|
||||
migrateDataDescription: "从旧版 JSON 文件迁移数据到新的 SQLite 数据库。此操作可以安全地多次运行(将跳过重复项)。",
|
||||
migrateDataButton: "从 JSON 迁移数据",
|
||||
scanFiles: "扫描文件",
|
||||
scanFilesSuccess: "扫描完成。添加了 {count} 个新视频。",
|
||||
scanFilesFailed: "扫描失败",
|
||||
migrateConfirmation: "确定要迁移数据吗?这可能需要一些时间。",
|
||||
migrationResults: "迁移结果",
|
||||
migrationReport: "迁移报告",
|
||||
@@ -324,6 +338,9 @@ export const translations = {
|
||||
rateThisVideo: "给视频评分",
|
||||
enterFullscreen: "全屏",
|
||||
exitFullscreen: "退出全屏",
|
||||
editTitle: "编辑标题",
|
||||
titleUpdated: "标题更新成功",
|
||||
titleUpdateFailed: "更新标题失败",
|
||||
|
||||
// Login
|
||||
signIn: "登录",
|
||||
@@ -356,6 +373,7 @@ export const translations = {
|
||||
success: "成功",
|
||||
cancel: "取消",
|
||||
confirm: "确认",
|
||||
save: "保存",
|
||||
on: "开启",
|
||||
off: "关闭",
|
||||
|
||||
|
||||
35
walkthrough.md
Normal file
35
walkthrough.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Walkthrough - Video Title Editing
|
||||
|
||||
I have added the ability to edit video titles directly from the video player page.
|
||||
|
||||
## Changes
|
||||
|
||||
### Backend
|
||||
|
||||
#### [api.ts](file:///Users/franklioxygen/Projects/mytube/backend/src/routes/api.ts)
|
||||
- Added `PUT /videos/:id` route.
|
||||
|
||||
#### [videoController.ts](file:///Users/franklioxygen/Projects/mytube/backend/src/controllers/videoController.ts)
|
||||
- Added `updateVideoDetails` controller to handle title updates.
|
||||
|
||||
### Frontend
|
||||
|
||||
#### [VideoPlayer.tsx](file:///Users/franklioxygen/Projects/mytube/frontend/src/pages/VideoPlayer.tsx)
|
||||
- Added a pencil icon next to the video title.
|
||||
- Clicking the icon switches the title to an input field.
|
||||
- Added "Save" and "Cancel" buttons (icon-only).
|
||||
- Implemented logic to save the new title to the backend.
|
||||
|
||||
#### [translations.ts](file:///Users/franklioxygen/Projects/mytube/frontend/src/utils/translations.ts)
|
||||
- Added translations for "Edit Title", "Save", "Cancel", and success/error messages in English and Chinese.
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Manual Verification
|
||||
- **UI**: The pencil icon appears next to the title.
|
||||
- **Interaction**: Clicking the icon shows the input field and buttons.
|
||||
- **Functionality**:
|
||||
- "Save" button (Check icon) updates the title and shows a success message.
|
||||
- "Cancel" button (Close icon) reverts the changes.
|
||||
- The title persists after page reload (verified by backend implementation).
|
||||
- **Translations**: Verified keys for both languages.
|
||||
Reference in New Issue
Block a user