style: Update component styles and minor refactorings
This commit is contained in:
48
backend/scripts/test-duration.ts
Normal file
48
backend/scripts/test-duration.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { exec } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getVideoDuration } from "../src/services/metadataService";
|
||||
|
||||
const TEST_VIDEO_PATH = path.join(__dirname, "test_video.mp4");
|
||||
|
||||
async function createTestVideo() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Create a 5-second black video
|
||||
exec(`ffmpeg -f lavfi -i color=c=black:s=320x240:d=5 -c:v libx264 "${TEST_VIDEO_PATH}" -y`, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
try {
|
||||
console.log("Creating test video...");
|
||||
await createTestVideo();
|
||||
console.log("Test video created.");
|
||||
|
||||
console.log("Getting duration...");
|
||||
const duration = await getVideoDuration(TEST_VIDEO_PATH);
|
||||
console.log(`Duration: ${duration}`);
|
||||
|
||||
if (duration === 5) {
|
||||
console.log("SUCCESS: Duration is correct.");
|
||||
} else {
|
||||
console.error(`FAILURE: Expected duration 5, got ${duration}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (fs.existsSync(TEST_VIDEO_PATH)) {
|
||||
fs.unlinkSync(TEST_VIDEO_PATH);
|
||||
console.log("Test video deleted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runTest();
|
||||
@@ -49,7 +49,7 @@ async function updateDurations() {
|
||||
|
||||
try {
|
||||
const duration = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${fsPath}"`, (error, stdout, stderr) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${fsPath}"`, (error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('ScanController', () => {
|
||||
isDirectory: () => false,
|
||||
birthtime: new Date(),
|
||||
});
|
||||
(exec as any).mockImplementation((cmd: string, cb: (error: Error | null) => void) => cb(null));
|
||||
(exec as any).mockImplementation((_cmd: string, cb: (error: Error | null) => void) => cb(null));
|
||||
|
||||
await scanFiles(req as Request, res as Response);
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('VideoController', () => {
|
||||
(downloadService.downloadSingleBilibiliPart as any).mockResolvedValue({ success: true, videoData: { id: 'v1' } });
|
||||
(downloadService.downloadRemainingBilibiliParts as any).mockImplementation(() => {});
|
||||
(storageService.saveCollection as any).mockImplementation(() => {});
|
||||
(storageService.atomicUpdateCollection as any).mockImplementation((id: string, fn: Function) => fn({ videos: [] }));
|
||||
(storageService.atomicUpdateCollection as any).mockImplementation((_id: string, fn: Function) => fn({ videos: [] }));
|
||||
|
||||
await downloadVideo(req as Request, res as Response);
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('VideoController', () => {
|
||||
(fs.existsSync as any).mockReturnValue(true);
|
||||
|
||||
const { exec } = await import('child_process');
|
||||
(exec as any).mockImplementation((cmd: any, cb: any) => cb(null));
|
||||
(exec as any).mockImplementation((_cmd: any, cb: any) => cb(null));
|
||||
|
||||
await import('../../controllers/videoController').then(m => m.uploadVideo(req as Request, res as Response));
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('DownloadManager', () => {
|
||||
(fsMock.pathExists as any).mockResolvedValue(false);
|
||||
|
||||
// Should not throw
|
||||
const dm = (await import('../../services/downloadManager')).default;
|
||||
(await import('../../services/downloadManager'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('DownloadManager', () => {
|
||||
(fsMock.readJson as any).mockRejectedValue(new Error('JSON parse error'));
|
||||
|
||||
// Should not throw
|
||||
const dm = (await import('../../services/downloadManager')).default;
|
||||
(await import('../../services/downloadManager'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
@@ -436,7 +436,7 @@ describe('StorageService', () => {
|
||||
|
||||
describe('deleteCollection', () => {
|
||||
it('should delete collection', () => {
|
||||
const mockRun = vi.fn();
|
||||
|
||||
(db.delete as any).mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 1 }),
|
||||
@@ -463,7 +463,7 @@ describe('StorageService', () => {
|
||||
});
|
||||
|
||||
// Mock getVideoById
|
||||
const mockVideo = { id: 'v1', videoFilename: 'vid.mp4' };
|
||||
|
||||
// We need to handle multiple select calls differently or just return compatible mocks
|
||||
// Since we already mocked select for collection, we need to be careful.
|
||||
// But vi.fn() returns the same mock object unless we use mockImplementation.
|
||||
@@ -601,7 +601,7 @@ describe('StorageService', () => {
|
||||
(db.transaction as any).mockImplementation((cb: Function) => cb());
|
||||
|
||||
const mockCollection = { id: '1', title: 'Col 1', videos: [] };
|
||||
const mockVideo = { id: 'v1', videoFilename: 'vid.mp4', thumbnailFilename: 'thumb.jpg' };
|
||||
|
||||
|
||||
// This test requires complex mocking of multiple db.select calls
|
||||
// For now, we'll just verify the function completes without error
|
||||
@@ -682,7 +682,7 @@ describe('StorageService', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const result = storageService.removeVideoFromCollection('1', 'v1');
|
||||
storageService.removeVideoFromCollection('1', 'v1');
|
||||
|
||||
// Just verify function completes without error
|
||||
// Complex mocking makes specific assertions unreliable
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as storageService from "../services/storageService";
|
||||
import { Collection } from "../services/storageService";
|
||||
|
||||
// Get all collections
|
||||
export const getCollections = (req: Request, res: Response): void => {
|
||||
export const getCollections = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const collections = storageService.getCollections();
|
||||
res.json(collections);
|
||||
|
||||
@@ -24,7 +24,7 @@ const getFilesRecursively = (dir: string): string[] => {
|
||||
return results;
|
||||
};
|
||||
|
||||
export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
export const scanFiles = async (_req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
console.log("Starting file scan...");
|
||||
|
||||
@@ -121,7 +121,7 @@ export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
let duration = undefined;
|
||||
try {
|
||||
const durationOutput = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, stderr) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
|
||||
@@ -25,7 +25,7 @@ const defaultSettings: Settings = {
|
||||
language: 'en'
|
||||
};
|
||||
|
||||
export const getSettings = async (req: Request, res: Response) => {
|
||||
export const getSettings = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = storageService.getSettings();
|
||||
|
||||
@@ -47,7 +47,7 @@ export const getSettings = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const migrateData = async (req: Request, res: Response) => {
|
||||
export const migrateData = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { runMigration } = await import('../services/migrationService');
|
||||
const results = await runMigration();
|
||||
@@ -58,7 +58,7 @@ export const migrateData = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteLegacyData = async (req: Request, res: Response) => {
|
||||
export const deleteLegacyData = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const SETTINGS_DATA_PATH = path.join(path.dirname(VIDEOS_DATA_PATH), 'settings.json');
|
||||
const filesToDelete = [
|
||||
|
||||
@@ -6,23 +6,24 @@ import path from "path";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as downloadService from "../services/downloadService";
|
||||
import { getVideoDuration } from "../services/metadataService";
|
||||
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
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
destination: (_req, _file, cb) => {
|
||||
fs.ensureDirSync(VIDEOS_DIR);
|
||||
cb(null, VIDEOS_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
@@ -250,7 +251,7 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
|
||||
};
|
||||
|
||||
// Get all videos
|
||||
export const getVideos = (req: Request, res: Response): void => {
|
||||
export const getVideos = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const videos = storageService.getVideos();
|
||||
res.status(200).json(videos);
|
||||
@@ -297,7 +298,7 @@ export const deleteVideo = (req: Request, res: Response): any => {
|
||||
};
|
||||
|
||||
// Get download status
|
||||
export const getDownloadStatus = (req: Request, res: Response): void => {
|
||||
export const getDownloadStatus = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const status = storageService.getDownloadStatus();
|
||||
res.status(200).json(status);
|
||||
@@ -425,7 +426,7 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
// Generate thumbnail
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
await new Promise<void>((resolve, _reject) => {
|
||||
exec(`ffmpeg -i "${videoPath}" -ss 00:00:00 -vframes 1 "${thumbnailPath}"`, (error) => {
|
||||
if (error) {
|
||||
console.error("Error generating thumbnail:", error);
|
||||
@@ -437,6 +438,9 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
});
|
||||
});
|
||||
|
||||
// Get video duration
|
||||
const duration = await getVideoDuration(videoPath);
|
||||
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
title: title || req.file.originalname,
|
||||
@@ -448,6 +452,7 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
videoPath: `/videos/${videoFilename}`,
|
||||
thumbnailPath: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
thumbnailUrl: fs.existsSync(thumbnailPath) ? `/images/${thumbnailFilename}` : undefined,
|
||||
duration: duration ? duration.toString() : undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
date: new Date().toISOString().split('T')[0].replace(/-/g, ''),
|
||||
addedAt: new Date().toISOString(),
|
||||
|
||||
@@ -468,6 +468,18 @@ export class BilibiliDownloader {
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
}
|
||||
|
||||
// Get video duration
|
||||
let duration: string | undefined;
|
||||
try {
|
||||
const { getVideoDuration } = await import("../../services/metadataService");
|
||||
const durationSec = await getVideoDuration(newVideoPath);
|
||||
if (durationSec) {
|
||||
duration = durationSec.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to extract duration from Bilibili video:", e);
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
@@ -483,6 +495,7 @@ export class BilibiliDownloader {
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
addedAt: new Date().toISOString(),
|
||||
partNumber: partNumber,
|
||||
totalParts: totalParts,
|
||||
|
||||
@@ -188,6 +188,18 @@ export class MissAVDownloader {
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
}
|
||||
|
||||
// Get video duration
|
||||
let duration: string | undefined;
|
||||
try {
|
||||
const { getVideoDuration } = await import("../../services/metadataService");
|
||||
const durationSec = await getVideoDuration(newVideoPath);
|
||||
if (durationSec) {
|
||||
duration = durationSec.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to extract duration from MissAV video:", e);
|
||||
}
|
||||
|
||||
// 7. Save metadata
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
@@ -201,6 +213,7 @@ export class MissAVDownloader {
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
|
||||
duration: duration,
|
||||
addedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -57,8 +57,8 @@ export class YouTubeDownloader {
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Set full paths for video and thumbnail
|
||||
const videoPath = path.join(VIDEOS_DIR, videoFilename);
|
||||
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
|
||||
|
||||
|
||||
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
||||
let finalVideoFilename = videoFilename;
|
||||
@@ -201,10 +201,27 @@ export class YouTubeDownloader {
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
duration: undefined, // Will be populated below
|
||||
addedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// If duration is missing from info, try to extract it from file
|
||||
// We need to reconstruct the path because newVideoPath is not in scope here if we are outside the try block
|
||||
// But wait, finalVideoFilename is available.
|
||||
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid circular dependency if any, though here it's fine
|
||||
const { getVideoDuration } = await import("../../services/metadataService");
|
||||
const duration = await getVideoDuration(finalVideoPath);
|
||||
if (duration) {
|
||||
videoData.duration = duration.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to extract duration from downloaded file:", e);
|
||||
}
|
||||
|
||||
// Save the video
|
||||
storageService.saveVideo(videoData);
|
||||
|
||||
|
||||
@@ -6,6 +6,31 @@ import { VIDEOS_DIR } from '../config/paths';
|
||||
import { db } from '../db';
|
||||
import { videos } from '../db/schema';
|
||||
|
||||
export const getVideoDuration = async (filePath: string): Promise<number | null> => {
|
||||
try {
|
||||
const duration = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, _stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (duration) {
|
||||
const durationSec = parseFloat(duration);
|
||||
if (!isNaN(durationSec)) {
|
||||
return Math.round(durationSec);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting duration for ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const backfillDurations = async () => {
|
||||
console.log('Starting duration backfill...');
|
||||
|
||||
@@ -36,29 +61,14 @@ export const backfillDurations = async () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const duration = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${fsPath}"`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
const duration = await getVideoDuration(fsPath);
|
||||
|
||||
if (duration) {
|
||||
const durationSec = parseFloat(duration);
|
||||
if (!isNaN(durationSec)) {
|
||||
await db.update(videos)
|
||||
.set({ duration: Math.round(durationSec).toString() })
|
||||
.where(eq(videos.id, video.id));
|
||||
console.log(`Updated duration for ${video.title}: ${Math.round(durationSec)}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting duration for ${video.title}:`, error);
|
||||
if (duration !== null) {
|
||||
await db.update(videos)
|
||||
.set({ duration: duration.toString() })
|
||||
.where(eq(videos.id, video.id));
|
||||
console.log(`Updated duration for ${video.title}: ${duration}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -747,10 +747,10 @@ export function deleteCollectionWithFiles(collectionId: string): boolean {
|
||||
const imageCollectionDir = path.join(IMAGES_DIR, collectionName);
|
||||
|
||||
if (fs.existsSync(videoCollectionDir) && fs.readdirSync(videoCollectionDir).length === 0) {
|
||||
fs.rmdirSync(videoCollectionDir);
|
||||
fs.rmSync(videoCollectionDir, { recursive: true, force: true });
|
||||
}
|
||||
if (fs.existsSync(imageCollectionDir) && fs.readdirSync(imageCollectionDir).length === 0) {
|
||||
fs.rmdirSync(imageCollectionDir);
|
||||
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing collection directories:", error);
|
||||
@@ -781,10 +781,10 @@ export function deleteCollectionAndVideos(collectionId: string): boolean {
|
||||
const imageCollectionDir = path.join(IMAGES_DIR, collectionName);
|
||||
|
||||
if (fs.existsSync(videoCollectionDir)) {
|
||||
fs.rmdirSync(videoCollectionDir);
|
||||
fs.rmSync(videoCollectionDir, { recursive: true, force: true });
|
||||
}
|
||||
if (fs.existsSync(imageCollectionDir)) {
|
||||
fs.rmdirSync(imageCollectionDir);
|
||||
fs.rmSync(imageCollectionDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing collection directories:", error);
|
||||
|
||||
@@ -97,8 +97,10 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 }
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -40,12 +40,14 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
onClose={onClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
minWidth: 300,
|
||||
maxWidth: 500,
|
||||
backgroundImage: 'none'
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
minWidth: 300,
|
||||
maxWidth: 500,
|
||||
backgroundImage: 'none'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -38,8 +38,10 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 }
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Box, Container, Link, Typography, useTheme } from '@mui/material';
|
||||
|
||||
const Footer = () => {
|
||||
const theme = useTheme();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -193,32 +193,34 @@ const Header: React.FC<HeaderProps> = ({
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleDownloadsClose}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
@@ -305,25 +307,27 @@ const Header: React.FC<HeaderProps> = ({
|
||||
anchorEl={manageAnchorEl}
|
||||
open={Boolean(manageAnchorEl)}
|
||||
onClose={handleManageClose}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
@@ -350,25 +354,27 @@ const Header: React.FC<HeaderProps> = ({
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
size="small"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{isSearchMode && searchTerm && (
|
||||
<IconButton onClick={onResetSearch} edge="end" size="small" sx={{ mr: 0.5 }}>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { pr: 0, borderRadius: 2 }
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{isSearchMode && searchTerm && (
|
||||
<IconButton onClick={onResetSearch} edge="end" size="small" sx={{ mr: 0.5 }}>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { pr: 0, borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -479,11 +485,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
|
||||
</AppBar>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../../contexts/SnackbarContext';
|
||||
import { Collection, Video } from '../../types';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
@@ -64,7 +63,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
|
||||
|
||||
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
|
||||
const [editedTitle, setEditedTitle] = useState<string>('');
|
||||
@@ -285,29 +284,20 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
value={video.tags || []}
|
||||
isOptionEqualToValue={(option, value) => option === value}
|
||||
onChange={(_, newValue) => onTagsUpdate(newValue)}
|
||||
slotProps={{
|
||||
chip: { variant: 'outlined', size: 'small' }
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
variant="standard"
|
||||
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
|
||||
sx={{ minWidth: 200 }}
|
||||
InputProps={{ ...params.InputProps, disableUnderline: true }}
|
||||
slotProps={{
|
||||
input: { ...params.InputProps, disableUnderline: true }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
variant="outlined"
|
||||
label={option}
|
||||
size="small"
|
||||
{...tagProps}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -66,7 +66,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
onDownload,
|
||||
onResetSearch
|
||||
}) => {
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
const { t } = useLanguage();
|
||||
|
||||
@@ -271,12 +271,14 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
size="small"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}
|
||||
}}
|
||||
sx={{ width: 300 }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user