feat: Add itemsPerPage setting to GeneralSettings

This commit is contained in:
Peifan Li
2025-12-08 16:57:46 -05:00
parent 90435fd841
commit f842394e66
17 changed files with 119 additions and 20 deletions

View File

@@ -72,6 +72,19 @@ describe('SettingsController', () => {
expect(bcrypt.hash).toHaveBeenCalledWith('pass', 'salt');
});
it('should validate and update itemsPerPage', async () => {
req.body = { itemsPerPage: -5 };
(storageService.getSettings as any).mockReturnValue({});
await updateSettings(req as Request, res as Response);
expect(storageService.saveSettings).toHaveBeenCalledWith(expect.objectContaining({ itemsPerPage: 12 }));
req.body = { itemsPerPage: 20 };
await updateSettings(req as Request, res as Response);
expect(storageService.saveSettings).toHaveBeenCalledWith(expect.objectContaining({ itemsPerPage: 20 }));
});
});
describe('verifyPassword', () => {

View File

@@ -21,6 +21,7 @@ interface Settings {
homeSidebarOpen?: boolean;
subtitlesEnabled?: boolean;
websiteName?: string;
itemsPerPage?: number;
}
const defaultSettings: Settings = {
@@ -36,7 +37,8 @@ const defaultSettings: Settings = {
cloudDrivePath: '',
homeSidebarOpen: true,
subtitlesEnabled: true,
websiteName: 'MyTube'
websiteName: 'MyTube',
itemsPerPage: 12
};
export const getSettings = async (_req: Request, res: Response) => {
@@ -119,6 +121,10 @@ export const updateSettings = async (req: Request, res: Response) => {
newSettings.websiteName = newSettings.websiteName.substring(0, 15);
}
if (newSettings.itemsPerPage && newSettings.itemsPerPage < 1) {
newSettings.itemsPerPage = 12; // Default fallback if invalid
}
// Handle password hashing
if (newSettings.password) {
// If password is provided, hash it

View File

@@ -10,7 +10,7 @@
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#ff3e3e" />
<title>MyTube - Download & Watch YouTube Videos</title>
<title>MyTube - My Videos, My Rules.</title>
</head>
<body>
<div id="root"></div>

View File

@@ -5,10 +5,12 @@ import { useLanguage } from '../../contexts/LanguageContext';
interface GeneralSettingsProps {
language: string;
websiteName?: string;
onChange: (field: string, value: string) => void;
itemsPerPage?: number;
onChange: (field: string, value: string | number) => void;
}
const GeneralSettings: React.FC<GeneralSettingsProps> = ({ language, websiteName, onChange }) => {
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
const { language, websiteName, onChange } = props;
const { t } = useLanguage();
return (
@@ -46,6 +48,21 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = ({ language, websiteName
helperText={`${(websiteName || '').length}/15 characters (Default: MyTube)`}
slotProps={{ htmlInput: { maxLength: 15 } }}
/>
<TextField
fullWidth
label={t('itemsPerPage') || "Items Per Page"}
type="number"
value={props.itemsPerPage || 12}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val > 0) {
onChange('itemsPerPage', val);
}
}}
helperText={t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)"}
slotProps={{ htmlInput: { min: 1 } }}
/>
</Box>
</Box>
);

View File

@@ -14,7 +14,8 @@ import {
Typography
} from '@mui/material';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import AuthorsList from '../components/AuthorsList';
import CollectionCard from '../components/CollectionCard';
import Collections from '../components/Collections';
@@ -39,8 +40,10 @@ const Home: React.FC = () => {
const { collections } = useCollection();
const [page, setPage] = useState(1);
const ITEMS_PER_PAGE = 12;
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '1', 10);
const [itemsPerPage, setItemsPerPage] = useState(12);
const [viewMode, setViewMode] = useState<'collections' | 'all-videos' | 'history'>(() => {
const saved = localStorage.getItem('homeViewMode');
return (saved as 'collections' | 'all-videos' | 'history') || 'collections';
@@ -53,8 +56,13 @@ const Home: React.FC = () => {
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
if (response.data && typeof response.data.homeSidebarOpen !== 'undefined') {
setIsSidebarOpen(response.data.homeSidebarOpen);
if (response.data) {
if (typeof response.data.homeSidebarOpen !== 'undefined') {
setIsSidebarOpen(response.data.homeSidebarOpen);
}
if (typeof response.data.itemsPerPage !== 'undefined') {
setItemsPerPage(response.data.itemsPerPage);
}
}
} catch (error) {
console.error('Failed to fetch settings:', error);
@@ -95,9 +103,33 @@ const Home: React.FC = () => {
// Reset page when filters change
useEffect(() => {
setPage(1);
}, [videos, collections, selectedTags]);
// Only reset to page 1 if we are not already on page 1
// This effect might run on mount too, so we need to be careful not to overwrite the URL param if it was just set
// However, if videos/collections changes, we generally DO want to reset to page 1 as the data changed.
// But if we just navigated back, videos might be re-fetched.
// If the data is stable, we shouldn't reset.
// Actually, preventing reset on initial load is hard if we depend on `videos`.
// Let's rely on the user manual action for now, OR better:
// When videos change, if the current page is out of bounds, reset it.
// Or if the user changes tags, definitely reset.
// But for just 'videos' update (like background refresh), maybe we shouldn't reset unless necessary.
// For now, let's ONLY reset if tags change.
}, [selectedTags]);
// Reset page when switching view modes or tags
// We use a ref to track previous tags so we don't reset on mount (when navigating back)
const prevTagsRef = useRef(selectedTags);
useEffect(() => {
if (prevTagsRef.current !== selectedTags) {
prevTagsRef.current = selectedTags;
setSearchParams((prev: URLSearchParams) => {
const newParams = new URLSearchParams(prev);
newParams.set('page', '1');
return newParams;
});
}
}, [selectedTags, setSearchParams]);
// Add default empty array to ensure videos is always an array
@@ -164,18 +196,26 @@ const Home: React.FC = () => {
const handleViewModeChange = (mode: 'collections' | 'all-videos' | 'history') => {
setViewMode(mode);
localStorage.setItem('homeViewMode', mode);
setPage(1); // Reset pagination
setSearchParams((prev: URLSearchParams) => {
const newParams = new URLSearchParams(prev);
newParams.set('page', '1');
return newParams;
});
};
// Pagination logic
const totalPages = Math.ceil(filteredVideos.length / ITEMS_PER_PAGE);
const totalPages = Math.ceil(filteredVideos.length / itemsPerPage);
const displayedVideos = filteredVideos.slice(
(page - 1) * ITEMS_PER_PAGE,
page * ITEMS_PER_PAGE
(page - 1) * itemsPerPage,
page * itemsPerPage
);
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
setSearchParams((prev: URLSearchParams) => {
const newParams = new URLSearchParams(prev);
newParams.set('page', value.toString());
return newParams;
});
window.scrollTo({ top: 0, behavior: 'smooth' });
};

View File

@@ -51,7 +51,8 @@ const SettingsPage: React.FC = () => {
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: ''
cloudDrivePath: '',
itemsPerPage: 12
});
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
@@ -258,6 +259,7 @@ const SettingsPage: React.FC = () => {
<GeneralSettings
language={settings.language}
websiteName={settings.websiteName}
itemsPerPage={settings.itemsPerPage}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Grid>

View File

@@ -69,4 +69,5 @@ export interface Settings {
homeSidebarOpen?: boolean;
subtitlesEnabled?: boolean;
websiteName?: string;
itemsPerPage?: number;
}

View File

@@ -84,7 +84,9 @@ export const ar = {
cleanupTempFilesDescription: "إزالة جميع ملفات التنزيل المؤقتة (.ytdl، .part) من دليل التحميلات. يساعد هذا في تحرير مساحة القرص من التنزيلات غير المكتملة أو الملغاة.",
cleanupTempFilesConfirmTitle: "تنظيف الملفات المؤقتة؟",
cleanupTempFilesConfirmMessage: "سيؤدي هذا إلى حذف جميع ملفات .ytdl و .part في دليل التحميلات نهائيًا. تأكد من عدم وجود تنزيلات نشطة قبل المتابعة.",
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء وجود تنزيلات نشطة. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء نشاط التنزيلات. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
itemsPerPage: "عناصر لكل صفحة",
itemsPerPageHelper: "عدد مقاطع الفيديو المعروضة في كل صفحة (الافتراضي: 12)",
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",

View File

@@ -44,6 +44,8 @@ export const de = {
cleanupTempFilesConfirmTitle: "Temporäre Dateien bereinigen?",
cleanupTempFilesConfirmMessage: "Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine aktiven Downloads vorhanden sind, bevor Sie fortfahren.",
cleanupTempFilesActiveDownloads: "Bereinigung nicht möglich, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie ab.",
itemsPerPage: "Elemente pro Seite",
itemsPerPageHelper: "Anzahl der Videos pro Seite (Standard: 12)",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",

View File

@@ -85,7 +85,9 @@ export const en = {
cleanupTempFilesDescription: "Remove all temporary download files (.ytdl, .part) from the uploads directory. This helps free up disk space from incomplete or cancelled downloads.",
cleanupTempFilesConfirmTitle: "Clean Up Temporary Files?",
cleanupTempFilesConfirmMessage: "This will permanently delete all .ytdl and .part files in the uploads directory. Make sure there are no active downloads before proceeding.",
cleanupTempFilesActiveDownloads: "Cannot clean up while downloads are active. Please wait for all downloads to complete or cancel them first.",
cleanupTempFilesActiveDownloads: "Cannot clean up temporary files while downloads are active. Please wait for all downloads to complete or cancel them first.",
itemsPerPage: "Items Per Page",
itemsPerPageHelper: "Number of videos to show per page (Default: 12)",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",

View File

@@ -42,6 +42,8 @@ export const es = {
cleanupTempFilesConfirmTitle: "¿Limpiar Archivos Temporales?",
cleanupTempFilesConfirmMessage: "Esto eliminará permanentemente todos los archivos .ytdl y .part en el directorio de cargas. Asegúrate de que no haya descargas activas antes de continuar.",
cleanupTempFilesActiveDownloads: "No se puede limpiar mientras hay descargas activas. Espera a que todas las descargas terminen o cancélalas primero.",
itemsPerPage: "Elementos por página",
itemsPerPageHelper: "Número de videos para mostrar por página (Predeterminado: 12)",
cleanupTempFilesSuccess: "Se eliminaron exitosamente {count} archivo(s) temporal(es).",
cleanupTempFilesFailed: "Error al limpiar archivos temporales",

View File

@@ -88,6 +88,8 @@ export const fr = {
cleanupTempFilesConfirmTitle: "Nettoyer les fichiers temporaires?",
cleanupTempFilesConfirmMessage: "Cela supprimera définitivement tous les fichiers .ytdl et .part dans le répertoire des téléversements. Assurez-vous qu'il n'y a pas de téléchargements actifs avant de continuer.",
cleanupTempFilesActiveDownloads: "Impossible de nettoyer pendant que des téléchargements sont actifs. Veuillez attendre la fin de tous les téléchargements ou les annuler d'abord.",
itemsPerPage: "Éléments par page",
itemsPerPageHelper: "Nombre de vidéos à afficher par page (Défaut : 12)",
cleanupTempFilesSuccess: "{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",

View File

@@ -85,6 +85,8 @@ export const ja = {
cleanupTempFilesConfirmTitle: "一時ファイルをクリーンアップしますか?",
cleanupTempFilesConfirmMessage: "これにより、アップロードディレクトリ内のすべての.ytdlおよび.partファイルが永久に削除されます。続行する前に、アクティブなダウンロードがないことを確認してください。",
cleanupTempFilesActiveDownloads: "ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
itemsPerPage: "1ページあたりの項目数",
itemsPerPageHelper: "1ページに表示する動画の数 (デフォルト: 12)",
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",

View File

@@ -84,7 +84,9 @@ export const ko = {
cleanupTempFilesDescription: "업로드 디렉토리에서 모든 임시 다운로드 파일(.ytdl, .part)을 제거합니다. 불완전하거나 취소된 다운로드의 디스크 공간을 확보하는 데 도움이 됩니다.",
cleanupTempFilesConfirmTitle: "임시 파일을 정리하시겠습니까?",
cleanupTempFilesConfirmMessage: "업로드 디렉토리의 모든 .ytdl 및 .part 파일이 영구적으로 삭제됩니다. 계속하기 전에 활성 다운로드가 없는지 확인하세요.",
cleanupTempFilesActiveDownloads: "다운로드가 활성화된 동안에는 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하세요.",
cleanupTempFilesActiveDownloads: "다운로드가 진행되는 동안 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하십시오.",
itemsPerPage: "페이지 당 항목 수",
itemsPerPageHelper: "페이지 당 표시할 비디오 수 (기본값: 12)",
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
cleanupTempFilesFailed: "임시 파일 정리 실패",

View File

@@ -85,6 +85,8 @@ export const pt = {
cleanupTempFilesConfirmTitle: "Limpar Arquivos Temporários?",
cleanupTempFilesConfirmMessage: "Isto excluirá permanentemente todos os arquivos .ytdl e .part no diretório de uploads. Certifique-se de que não há downloads ativos antes de continuar.",
cleanupTempFilesActiveDownloads: "Não é possível limpar enquanto houver downloads ativos. Aguarde a conclusão de todos os downloads ou cancele-os primeiro.",
itemsPerPage: "Itens por página",
itemsPerPageHelper: "Número de vídeos a mostrar por página (Padrão: 12)",
cleanupTempFilesSuccess: "{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",

View File

@@ -85,6 +85,8 @@ export const ru = {
cleanupTempFilesConfirmTitle: "Очистить временные файлы?",
cleanupTempFilesConfirmMessage: "Это навсегда удалит все файлы .ytdl и .part в каталоге загрузок. Убедитесь, что нет активных загрузок перед продолжением.",
cleanupTempFilesActiveDownloads: "Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
itemsPerPage: "Элементов на странице",
itemsPerPageHelper: "Количество видео на странице (По умолчанию: 12)",
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
cleanupTempFilesFailed: "Не удалось очистить временные файлы",

View File

@@ -93,6 +93,8 @@ export const zh = {
"这将永久删除上传目录中的所有.ytdl和.part文件。请确保没有正在进行的下载。",
cleanupTempFilesActiveDownloads:
"有活动下载时无法清理。请等待所有下载完成或取消它们。",
itemsPerPage: "每页显示数量",
itemsPerPageHelper: "每页显示的视频数量 (默认: 12)",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",