feat: Add itemsPerPage setting to GeneralSettings
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' });
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -69,4 +69,5 @@ export interface Settings {
|
||||
homeSidebarOpen?: boolean;
|
||||
subtitlesEnabled?: boolean;
|
||||
websiteName?: string;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,9 @@ export const ar = {
|
||||
cleanupTempFilesDescription: "إزالة جميع ملفات التنزيل المؤقتة (.ytdl، .part) من دليل التحميلات. يساعد هذا في تحرير مساحة القرص من التنزيلات غير المكتملة أو الملغاة.",
|
||||
cleanupTempFilesConfirmTitle: "تنظيف الملفات المؤقتة؟",
|
||||
cleanupTempFilesConfirmMessage: "سيؤدي هذا إلى حذف جميع ملفات .ytdl و .part في دليل التحميلات نهائيًا. تأكد من عدم وجود تنزيلات نشطة قبل المتابعة.",
|
||||
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء وجود تنزيلات نشطة. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
|
||||
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء نشاط التنزيلات. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
|
||||
itemsPerPage: "عناصر لكل صفحة",
|
||||
itemsPerPageHelper: "عدد مقاطع الفيديو المعروضة في كل صفحة (الافتراضي: 12)",
|
||||
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
|
||||
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ export const ja = {
|
||||
cleanupTempFilesConfirmTitle: "一時ファイルをクリーンアップしますか?",
|
||||
cleanupTempFilesConfirmMessage: "これにより、アップロードディレクトリ内のすべての.ytdlおよび.partファイルが永久に削除されます。続行する前に、アクティブなダウンロードがないことを確認してください。",
|
||||
cleanupTempFilesActiveDownloads: "ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
|
||||
itemsPerPage: "1ページあたりの項目数",
|
||||
itemsPerPageHelper: "1ページに表示する動画の数 (デフォルト: 12)",
|
||||
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
|
||||
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
|
||||
|
||||
|
||||
@@ -84,7 +84,9 @@ export const ko = {
|
||||
cleanupTempFilesDescription: "업로드 디렉토리에서 모든 임시 다운로드 파일(.ytdl, .part)을 제거합니다. 불완전하거나 취소된 다운로드의 디스크 공간을 확보하는 데 도움이 됩니다.",
|
||||
cleanupTempFilesConfirmTitle: "임시 파일을 정리하시겠습니까?",
|
||||
cleanupTempFilesConfirmMessage: "업로드 디렉토리의 모든 .ytdl 및 .part 파일이 영구적으로 삭제됩니다. 계속하기 전에 활성 다운로드가 없는지 확인하세요.",
|
||||
cleanupTempFilesActiveDownloads: "다운로드가 활성화된 동안에는 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하세요.",
|
||||
cleanupTempFilesActiveDownloads: "다운로드가 진행되는 동안 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하십시오.",
|
||||
itemsPerPage: "페이지 당 항목 수",
|
||||
itemsPerPageHelper: "페이지 당 표시할 비디오 수 (기본값: 12)",
|
||||
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
|
||||
cleanupTempFilesFailed: "임시 파일 정리 실패",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ export const ru = {
|
||||
cleanupTempFilesConfirmTitle: "Очистить временные файлы?",
|
||||
cleanupTempFilesConfirmMessage: "Это навсегда удалит все файлы .ytdl и .part в каталоге загрузок. Убедитесь, что нет активных загрузок перед продолжением.",
|
||||
cleanupTempFilesActiveDownloads: "Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
|
||||
itemsPerPage: "Элементов на странице",
|
||||
itemsPerPageHelper: "Количество видео на странице (По умолчанию: 12)",
|
||||
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
|
||||
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ export const zh = {
|
||||
"这将永久删除上传目录中的所有.ytdl和.part文件。请确保没有正在进行的下载。",
|
||||
cleanupTempFilesActiveDownloads:
|
||||
"有活动下载时无法清理。请等待所有下载完成或取消它们。",
|
||||
itemsPerPage: "每页显示数量",
|
||||
itemsPerPageHelper: "每页显示的视频数量 (默认: 12)",
|
||||
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
|
||||
cleanupTempFilesFailed: "清理临时文件失败",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user