Files
MyTube/frontend/src/pages/SettingsPage.tsx
2025-12-12 13:05:25 -05:00

523 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Alert,
Box,
Card,
CardContent,
Container,
Divider,
Grid,
Snackbar,
Typography
} from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import ConfirmationModal from '../components/ConfirmationModal';
import AdvancedSettings from '../components/Settings/AdvancedSettings';
import CloudDriveSettings from '../components/Settings/CloudDriveSettings';
import CookieSettings from '../components/Settings/CookieSettings';
import DatabaseSettings from '../components/Settings/DatabaseSettings';
import DownloadSettings from '../components/Settings/DownloadSettings';
import GeneralSettings from '../components/Settings/GeneralSettings';
import SecuritySettings from '../components/Settings/SecuritySettings';
import TagsSettings from '../components/Settings/TagsSettings';
import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
import YtDlpSettings from '../components/Settings/YtDlpSettings';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useDebounce } from '../hooks/useDebounce';
import { Settings } from '../types';
import ConsoleManager from '../utils/consoleManager';
import { SNACKBAR_AUTO_HIDE_DURATION } from '../utils/constants';
import { Language } from '../utils/translations';
const API_URL = import.meta.env.VITE_API_URL;
const SettingsPage: React.FC = () => {
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
const [settings, setSettings] = useState<Settings>({
loginEnabled: false,
password: '',
defaultAutoPlay: false,
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en',
tags: [],
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: '',
itemsPerPage: 12,
ytDlpConfig: '',
showYoutubeSearch: true,
proxyOnlyYoutube: false
});
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
const debouncedSettings = useDebounce(settings, 1000);
const lastSavedSettingsRef = React.useRef<Settings | null>(null);
// Modal states
const [showDeleteLegacyModal, setShowDeleteLegacyModal] = useState(false);
const [showFormatConfirmModal, setShowFormatConfirmModal] = useState(false);
const [showMigrateConfirmModal, setShowMigrateConfirmModal] = useState(false);
const [showCleanupTempFilesModal, setShowCleanupTempFilesModal] = 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());
// Fetch settings
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
}
});
useEffect(() => {
if (settingsData) {
const newSettings = {
...settingsData,
tags: settingsData.tags || []
};
setSettings(newSettings);
// Initialize sync reference with fetched data
if (!lastSavedSettingsRef.current) {
lastSavedSettingsRef.current = newSettings;
}
}
}, [settingsData]);
const areSettingsEqual = (s1: Settings, s2: Settings) => {
const { password: p1, ...rest1 } = s1;
const { password: p2, ...rest2 } = s2;
// If password is set in current settings, it's a change (we assume s2 is lastSaved, which has cleared password)
if (p1) return false;
return JSON.stringify(rest1) === JSON.stringify(rest2);
};
// Save settings mutation
const saveMutation = useMutation({
mutationFn: async (newSettings: Settings) => {
// Only send password if it has been changed (is not empty)
const settingsToSend = { ...newSettings };
if (!settingsToSend.password) {
delete settingsToSend.password;
}
await axios.post(`${API_URL}/settings`, settingsToSend);
},
onSuccess: (_data, variables) => {
// Do not invalidate queries to prevent overwriting user input while typing
setMessage({ text: t('settingsSaved'), type: 'success' });
// Update reference to the settings we just successfully saved
// We must clear password from the reference as it is cleared in state on success (effectively)
const { password, ...rest } = variables;
lastSavedSettingsRef.current = { ...rest, password: '' } as Settings;
},
onError: () => {
setMessage({ text: t('settingsFailed'), type: 'error' });
}
});
// Autosave effect
useEffect(() => {
if (!lastSavedSettingsRef.current) return;
if (!areSettingsEqual(debouncedSettings, lastSavedSettingsRef.current)) {
// Check saveMutation.isPending
if (!saveMutation.isPending) {
saveMutation.mutate(debouncedSettings);
}
}
}, [debouncedSettings, saveMutation.isPending]);
// Migrate data mutation
const migrateMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/settings/migrate`);
return res.data.results;
},
onSuccess: (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'
});
},
onError: (error: any) => {
setInfoModal({
isOpen: true,
title: t('error'),
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
type: 'error'
});
}
});
// Cleanup temp files mutation
const cleanupMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/cleanup-temp-files`);
return res.data;
},
onSuccess: (data) => {
const { deletedCount, errors } = data;
let msg = t('cleanupTempFilesSuccess').replace('{count}', deletedCount.toString());
if (errors && errors.length > 0) {
msg += `\n\nErrors:\n${errors.join('\n')}`;
}
setInfoModal({
isOpen: true,
title: t('success'),
message: msg,
type: errors && errors.length > 0 ? 'warning' : 'success'
});
},
onError: (error: any) => {
const errorMsg = error.response?.data?.error === "Cannot clean up while downloads are active"
? t('cleanupTempFilesActiveDownloads')
: `${t('cleanupTempFilesFailed')}: ${error.response?.data?.details || error.message}`;
setInfoModal({
isOpen: true,
title: t('error'),
message: errorMsg,
type: 'error'
});
}
});
// Delete legacy data mutation
const deleteLegacyMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
return res.data.results;
},
onSuccess: (results) => {
let msg = `${t('legacyDataDeleted')}\n`;
if (results.deleted.length > 0) {
msg += `\nDeleted: ${results.deleted.join(', ')}`;
}
if (results.failed.length > 0) {
msg += `\nFailed: ${results.failed.join(', ')}`;
}
setInfoModal({
isOpen: true,
title: t('success'),
message: msg,
type: 'success'
});
},
onError: (error: any) => {
setInfoModal({
isOpen: true,
title: t('error'),
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
type: 'error'
});
}
});
// Format legacy filenames mutation
const formatFilenamesMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/settings/format-filenames`);
return res.data.results;
},
onSuccess: (results) => {
// Construct message using translations
let msg = t('formatFilenamesSuccess')
.replace('{processed}', results.processed.toString())
.replace('{renamed}', results.renamed.toString())
.replace('{errors}', results.errors.toString());
if (results.details && results.details.length > 0) {
// truncate details if too long
const detailsToShow = results.details.slice(0, 10);
msg += `\n\n${t('formatFilenamesDetails')}\n${detailsToShow.join('\n')}`;
if (results.details.length > 10) {
msg += `\n${t('formatFilenamesMore').replace('{count}', (results.details.length - 10).toString())}`;
}
}
setInfoModal({
isOpen: true,
title: t('success'),
message: msg,
type: results.errors > 0 ? 'warning' : 'success'
});
},
onError: (error: any) => {
setInfoModal({
isOpen: true,
title: t('error'),
message: t('formatFilenamesError').replace('{error}', error.response?.data?.details || error.message),
type: 'error'
});
}
});
const handleChange = (field: keyof Settings, value: string | boolean | number) => {
setSettings(prev => ({ ...prev, [field]: value }));
if (field === 'language') {
setLanguage(value as Language);
}
};
const handleTagsChange = (newTags: string[]) => {
setSettings(prev => ({ ...prev, tags: newTags }));
};
const isSaving = saveMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending || formatFilenamesMutation.isPending;
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
{t('settings')}
</Typography>
</Box>
<Card variant="outlined">
<CardContent>
<Grid container spacing={4}>
{/* General Settings */}
<Grid size={12}>
<GeneralSettings
language={settings.language}
websiteName={settings.websiteName}
itemsPerPage={settings.itemsPerPage}
showYoutubeSearch={settings.showYoutubeSearch}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Cookie Upload Settings */}
<Grid size={12}>
<CookieSettings
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
onError={(msg) => setMessage({ text: msg, type: 'error' })}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Security Settings */}
<Grid size={12}>
<SecuritySettings
settings={settings}
onChange={handleChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Video Defaults */}
<Grid size={12}>
<VideoDefaultSettings
settings={settings}
onChange={handleChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Tags Management */}
<Grid size={12}>
<TagsSettings
tags={settings.tags}
onTagsChange={handleTagsChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Download Settings */}
<Grid size={12}>
<DownloadSettings
settings={settings}
onChange={handleChange}
activeDownloadsCount={activeDownloads.length}
onCleanup={() => setShowCleanupTempFilesModal(true)}
isSaving={isSaving}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Cloud Drive Settings */}
<Grid size={12}>
<CloudDriveSettings
settings={settings}
onChange={handleChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Database Settings */}
<Grid size={12}>
<DatabaseSettings
onMigrate={() => setShowMigrateConfirmModal(true)}
onDeleteLegacy={() => setShowDeleteLegacyModal(true)}
onFormatFilenames={() => setShowFormatConfirmModal(true)}
isSaving={isSaving}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* yt-dlp Configuration */}
<Grid size={12}>
<YtDlpSettings
config={settings.ytDlpConfig || ''}
proxyOnlyYoutube={settings.proxyOnlyYoutube || false}
onChange={(config) => handleChange('ytDlpConfig', config)}
onProxyOnlyYoutubeChange={(checked) => handleChange('proxyOnlyYoutube', checked)}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Advanced Settings */}
<Grid size={12}>
<AdvancedSettings
debugMode={debugMode}
onDebugModeChange={setDebugMode}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Snackbar
open={!!message}
autoHideDuration={SNACKBAR_AUTO_HIDE_DURATION}
onClose={() => setMessage(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={message?.type} onClose={() => setMessage(null)}>
{message?.text}
</Alert>
</Snackbar>
<ConfirmationModal
isOpen={showDeleteLegacyModal}
onClose={() => setShowDeleteLegacyModal(false)}
onConfirm={() => {
setShowDeleteLegacyModal(false);
deleteLegacyMutation.mutate();
}}
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={() => {
setShowMigrateConfirmModal(false);
migrateMutation.mutate();
}}
title={t('migrateDataButton')}
message={t('migrateConfirmation')}
confirmText={t('confirm')}
cancelText={t('cancel')}
/>
{/* Format Filenames Confirmation Modal */}
<ConfirmationModal
isOpen={showFormatConfirmModal}
onClose={() => setShowFormatConfirmModal(false)}
onConfirm={() => {
setShowFormatConfirmModal(false);
formatFilenamesMutation.mutate();
}}
title={t('formatLegacyFilenamesButton')}
message={t('formatLegacyFilenamesDescription')} // Reusing description as message, or could add a specific confirm message
confirmText={t('confirm')}
cancelText={t('cancel')}
isDanger={true}
/>
{/* Cleanup Temp Files Modal */}
<ConfirmationModal
isOpen={showCleanupTempFilesModal}
onClose={() => setShowCleanupTempFilesModal(false)}
onConfirm={() => {
setShowCleanupTempFilesModal(false);
cleanupMutation.mutate();
}}
title={t('cleanupTempFilesConfirmTitle')}
message={t('cleanupTempFilesConfirmMessage')}
confirmText={t('confirm')}
cancelText={t('cancel')}
isDanger={true}
/>
{/* 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 >
);
};
export default SettingsPage;