+ {logs.length > 0 ? (
+
+ {logs.filter(l => l.toLowerCase().includes(logFilter.toLowerCase())).map((line, i) => (
+
+ ))}
) : (
-
- {filteredLogs.map((parsed, index) => { // 'parsed' is { raw, level }
- // Get color based on parsed level
- const logColor = getLogColor(parsed.level); // Use parsed.level
-
- return (
-
- {/* Render the raw line */}
-
{parsed.raw}
-
- );
- })}
+
+
+
{t("logViewer.noLogs", "System Idle")}
)}
- {/* Footer */}
-
-
-
- {t("logViewer.logEntries", { count: filteredLogs.length })}
- {logs.length !== filteredLogs.length &&
- ` (filtered from ${logs.length})`}
-
-
•
-
- {t("logViewer.autoScrollStatus", {
- status: autoScroll ? t("logViewer.on") : t("logViewer.off"),
- })}
-
+
+
setAutoScroll(!autoScroll)} className={`flex items-center gap-3 px-6 py-3 rounded-2xl text-xs font-black tracking-widest transition-all duration-500 shadow-2xl border ${autoScroll ? 'bg-theme-primary text-white border-theme-primary scale-105' : 'bg-theme-card text-theme-muted border-white/10 grayscale'}`}>
+
+ {autoScroll ? t("logViewer.on", 'AUTO-SCROLL ON') : t("logViewer.off", 'SCROLL LOCKED')}
+
+
+ setWrapText(!wrapText)} className="p-4 bg-theme-card/80 backdrop-blur-xl border border-white/10 rounded-2xl text-theme-muted hover:text-theme-primary transition-all">
+ setIsFullScreen(!isFullScreen)} className="p-4 bg-theme-card/80 backdrop-blur-xl border border-white/10 rounded-2xl text-theme-muted hover:text-theme-primary transition-all">{isFullScreen ? : }
- {connected && (
-
-
-
{t("logViewer.receivingUpdates")}
-
- )}
- {isReconnecting && (
-
-
- {t("logViewer.status.reconnecting")}
-
- )}
+
);
-}
+};
export default LogViewer;
\ No newline at end of file
From dfdfb825ee38c74821be8289f8d63fcb4d32ea7f Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Thu, 18 Dec 2025 19:31:14 +0100
Subject: [PATCH 10/18] revert log view
---
webui/backend/main.py | 64 +-
webui/frontend/src/components/LogViewer.jsx | 1277 ++++++++++++++-----
2 files changed, 972 insertions(+), 369 deletions(-)
diff --git a/webui/backend/main.py b/webui/backend/main.py
index 489b4a46..58561b35 100644
--- a/webui/backend/main.py
+++ b/webui/backend/main.py
@@ -7339,41 +7339,39 @@ async def force_kill_script():
scheduler.is_running = False
return {"success": True, "message": "Script process cleared"}
-def get_directory_tree(root_path: Path, current_path: Path):
- """Recursive helper to build a folder/file tree."""
- items = []
- try:
- # Sort so directories appear first, then files alphabetically
- for path in sorted(current_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
- # Create a relative path for the frontend (e.g., 'rotatedlogs/tautulli.log')
- rel_path = str(path.relative_to(root_path)).replace("\\", "/")
-
- if path.is_dir():
- items.append({
- "name": path.name,
- "type": "directory",
- "path": rel_path,
- "children": get_directory_tree(root_path, path)
- })
- elif path.suffix in ['.log', '.csv', '.json', '.txt']:
- items.append({
- "name": path.name,
- "type": "file",
- "path": rel_path,
- "size": path.stat().st_size
- })
- except Exception as e:
- logger.error(f"Error scanning directory {current_path}: {e}")
- return items
@app.get("/api/logs")
-async def list_logs():
- """Returns a recursive tree of all log directories."""
- if not LOGS_DIR.exists():
- return {"logs": []}
-
- tree = get_directory_tree(LOGS_DIR, LOGS_DIR)
- return {"logs": tree}
+async def get_logs():
+ """Get available log files from both Logs and UILogs directories"""
+ log_files = []
+
+ # Get logs from main Logs directory
+ if LOGS_DIR.exists():
+ for log_file in LOGS_DIR.glob("*.log"):
+ stat = log_file.stat()
+ log_files.append(
+ {
+ "name": log_file.name,
+ "size": stat.st_size,
+ "modified": stat.st_mtime,
+ "directory": "Logs",
+ }
+ )
+
+ # Get logs from UILogs directory
+ if UI_LOGS_DIR.exists():
+ for log_file in UI_LOGS_DIR.glob("*.log"):
+ stat = log_file.stat()
+ log_files.append(
+ {
+ "name": log_file.name,
+ "size": stat.st_size,
+ "modified": stat.st_mtime,
+ "directory": "UILogs",
+ }
+ )
+
+ return {"logs": sorted(log_files, key=lambda x: x["modified"], reverse=True)}
@app.get("/api/logs/{log_name}")
diff --git a/webui/frontend/src/components/LogViewer.jsx b/webui/frontend/src/components/LogViewer.jsx
index b33e8f47..8504ccb1 100644
--- a/webui/frontend/src/components/LogViewer.jsx
+++ b/webui/frontend/src/components/LogViewer.jsx
@@ -1,442 +1,1047 @@
-import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
-import {
- Terminal, Search, FileText, ChevronDown, RefreshCw,
- Trash2, Download, AlertCircle, CheckCircle2,
- Settings, Filter, ArrowDown, Maximize2, Minimize2,
- Clock, Activity, Shield, HardDrive, List, Info,
- ExternalLink, Copy, ChevronRight, Folder, Hash,
- Layout, Eye, EyeOff, Monitor, History, LifeBuoy, Square, Loader2
-} from 'lucide-react';
+import React, { useState, useEffect, useRef, useMemo } from "react";
+import { useLocation } from "react-router-dom";
+import {
+ RefreshCw,
+ Download,
+ Trash2,
+ FileText,
+ CheckCircle,
+ Wifi,
+ WifiOff,
+ ChevronDown,
+ Activity,
+ Square,
+ Search,
+ Filter,
+ Database,
+ Loader2,
+ LifeBuoy,
+ X,
+} from "lucide-react";
import { useTranslation } from "react-i18next";
+import Notification from "./Notification";
import { useToast } from "../context/ToastContext";
-import { useLocation } from "react-router-dom";
const API_URL = "/api";
const isDev = import.meta.env.DEV;
-// --- Sub-Component: LogStat ---
-const LogStat = ({ icon: Icon, label, value, color }) => (
-
-
-
-
-
- {label}
- {value}
-
-
-);
-
-// --- Sub-Component: AnsiLine ---
-const AnsiLine = React.memo(({ line }) => {
- if (!line) return null;
-
- const parseAnsi = (text) => {
- const parts = [];
- let currentPart = { text: '', color: '', bg: '', bold: false };
- const ansiRegex = /\x1b\[(([0-9]+;?)*)m/g;
- let lastIndex = 0;
- let match;
-
- while ((match = ansiRegex.exec(text)) !== null) {
- const plainText = text.substring(lastIndex, match.index);
- if (plainText) parts.push({ ...currentPart, text: plainText });
-
- const codes = match[1].split(';');
- codes.forEach(code => {
- if (code === '0') currentPart = { text: '', color: '', bg: '', bold: false };
- else if (code === '1') currentPart.bold = true;
- else if (code.startsWith('3')) {
- const colors = ['text-zinc-400', 'text-red-400', 'text-green-400', 'text-yellow-400', 'text-blue-400', 'text-magenta-400', 'text-cyan-400', 'text-white'];
- currentPart.color = colors[parseInt(code[1])] || '';
- }
- });
- lastIndex = ansiRegex.lastIndex;
- }
-
- const remainingText = text.substring(lastIndex);
- if (remainingText) parts.push({ ...currentPart, text: remainingText });
-
- if (parts.length === 0) {
- const lower = text.toLowerCase();
- let color = 'text-theme-text/80';
- if (lower.includes('error')) color = 'text-red-400 font-bold';
- else if (lower.includes('warn')) color = 'text-yellow-400';
- else if (lower.includes('info')) color = 'text-blue-400';
- return
{text} ;
- }
+const getWebSocketURL = (logFile) => {
+ // Check if the page is loaded via HTTPS or HTTP
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+
+ const baseURL = isDev
+ ? `ws://localhost:3000/ws/logs`
+ : `${protocol}//${window.location.host}/ws/logs`; // Use the correct protocol
- return parts.map((p, i) => (
-
{p.text}
- ));
+ // Add log_file as query parameter
+ return `${baseURL}?log_file=${encodeURIComponent(logFile)}`;
+};
+
+// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+// ++ LOG LEVEL FILTER COMPONENT
+// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+const LogLevelFilter = ({ levelFilters, setLevelFilters }) => {
+ const { t } = useTranslation();
+ const filters = [
+ { key: "DEBUG", color: "text-purple-400", border: "border-purple-500/50" },
+ { key: "INFO", color: "text-blue-400", border: "border-blue-500/50" },
+ { key: "WARNING", color: "text-yellow-400", border: "border-yellow-500/50" },
+ { key: "ERROR", color: "text-red-400", border: "border-red-500/50" },
+ ];
+
+ const toggleFilter = (key) => {
+ setLevelFilters((prev) => ({
+ ...prev,
+ [key]: !prev[key],
+ }));
};
- return (
-
-
- {parseAnsi(line)}
-
-
- );
-});
+ const allOn = Object.values(levelFilters).every((v) => v);
+ const allOff = Object.values(levelFilters).every((v) => !v);
-// --- Sub-Component: LogTreeItem ---
-const LogTreeItem = ({ item, onSelect, selectedLog, level = 0 }) => {
- const [isOpen, setIsOpen] = useState(level < 1);
- const isSelected = selectedLog === item.path;
+ const toggleAll = () => {
+ const newState = !allOn;
+ setLevelFilters({
+ INFO: newState,
+ WARNING: newState,
+ ERROR: newState,
+ DEBUG: newState,
+ });
+ };
- if (item.type === "directory") {
- return (
-
+ return (
+
+
+
+ {allOn ? t("logViewer.allLevels") : t("logViewer.allLevels")}
+
+
+ {filters.map(({ key, color, border }) => (
{ e.stopPropagation(); setIsOpen(!isOpen); }}
- className="w-full px-4 py-2 text-left text-[10px] hover:bg-white/5 flex items-center gap-2 text-theme-muted font-bold tracking-widest border-b border-white/5 transition-all"
- style={{ paddingLeft: `${level * 16 + 12}px` }}
+ key={key}
+ onClick={() => toggleFilter(key)}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all shadow-sm border ${
+ levelFilters[key]
+ ? `bg-theme-primary/10 ${color} ${border}`
+ : `bg-theme-bg text-theme-muted border-theme opacity-50 hover:opacity-100`
+ }`}
>
-
-
- {item.name}
+
+ [{key}]
+
-
- {item.children?.map(child => (
-
- ))}
-
-
- );
- }
-
- return (
-
onSelect(item.path)}
- className={`w-full px-4 py-2 text-left text-sm transition-all flex items-center justify-between border-b border-white/5 group ${
- isSelected ? "bg-theme-primary/20 text-theme-primary border-l-2 border-l-theme-primary" : "text-theme-text hover:bg-white/10"
- }`}
- style={{ paddingLeft: `${level * 16 + 28}px` }}
- >
-
-
- {item.name}
-
- {(item.size / 1024).toFixed(1)} KB
-
+ ))}
+
);
};
+// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+// ++ END OF COMPONENT
+// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
-const LogViewer = () => {
+function LogViewer() {
const { t } = useTranslation();
const { showSuccess, showError, showInfo } = useToast();
const location = useLocation();
-
const [logs, setLogs] = useState([]);
const [availableLogs, setAvailableLogs] = useState([]);
- const [selectedLog, setSelectedLog] = useState("");
- const [isLoading, setIsLoading] = useState(false);
- const [isGatheringSupportZip, setIsGatheringSupportZip] = useState(false);
- const [isStopping, setIsStopping] = useState(false);
+
+ const [selectedLog, setSelectedLog] = useState(null); // Set to null initially
+
const [autoScroll, setAutoScroll] = useState(true);
- const [searchTerm, setSearchTerm] = useState("");
- const [logFilter, setLogFilter] = useState("");
+ const [connected, setConnected] = useState(false);
+ const [isReconnecting, setIsReconnecting] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
- const [status, setStatus] = useState('disconnected');
- const [scriptStatus, setScriptStatus] = useState({ running: false, current_mode: null });
- const [maxLines, setMaxLines] = useState(1000);
- const [wrapText, setWrapText] = useState(true);
- const [isFullScreen, setIsFullScreen] = useState(false);
-
- const scrollRef = useRef(null);
- const ws = useRef(null);
- const currentLogFileRef = useRef(null);
-
- // --- Helpers ---
- const getWebSocketURL = (logFile) => {
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const baseURL = isDev
- ? `ws://localhost:3000/ws/logs`
- : `${protocol}//${window.location.host}/ws/logs`;
- return `${baseURL}?log_file=${encodeURIComponent(logFile)}`;
+ const [loading, setLoading] = useState(false);
+ const [isLoadingFullLog, setIsLoadingFullLog] = useState(false);
+ const [isGatheringSupportZip, setIsGatheringSupportZip] = useState(false); // Added
+
+ // --- NEW FILTER STATE ---
+ const [searchTerm, setSearchTerm] = useState("");
+ const [levelFilters, setLevelFilters] = useState({
+ INFO: true,
+ WARNING: true,
+ ERROR: true,
+ DEBUG: true,
+ });
+ // --- END NEW FILTER STATE ---
+
+ const [status, setStatus] = useState({
+ running: false,
+ current_mode: null,
+ });
+ const logContainerRef = useRef(null);
+ const wsRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const reconnectTimeoutRef = useRef(null);
+ const currentLogFileRef = useRef(null); // Set to null initially
+ const isInitialLoad = useRef(true); // Prevent useEffect [selectedLog] from firing on init
+ const logBufferRef = useRef([]);
+ const parseLogLine = (line) => {
+ const cleanedLine = line.replace(/\x00/g, "").trim();
+ if (!cleanedLine) return { raw: null, level: null };
+
+ // Regex 1: New Backend/UI Log Format
+ // [2025-11-04 10:44:39] [INFO ] [BACKEND:backend.main:lifespan:1894] - Scheduler initialized and started
+ const backendLogPattern =
+ /^\[([^\]]+)\]\s*\[([^\]\s]+)\s*\]\s+\[([^\]]+)\]\s+-\s+(.*)$/;
+ let match = cleanedLine.match(backendLogPattern);
+ if (match) {
+ return {
+ level: match[2].trim(), // e.g., "INFO"
+ raw: line, // Return the original line
+ };
+ }
+
+ // Regex 2: Old Scriptlog Format
+ // [timestamp] [INFO] |L.123| message
+ const scriptLogPattern =
+ /^\[([^\]]+)\]\s*\[([^\]]+)\]\s*\|L\.(\d+)\s*\|\s*(.*)$/;
+ match = cleanedLine.match(scriptLogPattern);
+ if (match) {
+ return {
+ level: match[2].trim(), // e.g., "INFO"
+ raw: line, // Return the original line
+ };
+ }
+
+ // Return as raw if no match
+ return { raw: line, level: null }; // level is null
};
- const fetchStatus = useCallback(async () => {
+ const fetchStatus = async () => {
try {
const response = await fetch(`${API_URL}/status`);
const data = await response.json();
- setScriptStatus({ running: data.running || false, current_mode: data.current_mode || null });
- } catch (error) { console.error("Error fetching status:", error); }
- }, []);
+ setStatus({
+ running: data.running || false,
+ current_mode: data.current_mode || null,
+ });
+ } catch (error) {
+ console.error("Error fetching status:", error);
+ }
+ };
const stopScript = async () => {
- setIsStopping(true);
+ setLoading(true);
try {
- const response = await fetch(`${API_URL}/stop`, { method: "POST" });
+ const response = await fetch(`${API_URL}/stop`, {
+ method: "POST",
+ });
const data = await response.json();
if (data.success) {
showSuccess(t("logViewer.scriptStopped"));
fetchStatus();
- } else showError(t("logViewer.error", { message: data.message }));
- } catch (error) { showError(t("logViewer.error", { message: error.message })); }
- finally { setIsStopping(false); }
+ } else {
+ showError(t("logViewer.error", { message: data.message }));
+ }
+ } catch (error) {
+ showError(t("logViewer.error", { message: error.message }));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // This is only used to get the color, not for rendering
+ const LogLevel = ({ level }) => {
+ const levelLower = (level || "").toLowerCase().trim();
+ const colors = {
+ error: "#f87171",
+ warning: "#fbbf24",
+ warn: "#fbbf24",
+ info: "#42A5F5",
+ success: "#4ade80",
+ debug: "#c084fc",
+ default: "#9ca3af",
+ };
+ const color = colors[levelLower] || colors.default;
+ return
[{level}] ;
+ };
+
+ const getLogColor = (level) => {
+ const levelLower = (level || "").toLowerCase().trim();
+ const colors = {
+ error: "#f87171",
+ warning: "#fbbf24",
+ warn: "#fbbf24",
+ info: "#42A5F5",
+ success: "#4ade80",
+ debug: "#c084fc",
+ default: "#d1d5db", // Default color for raw/unknown
+ };
+ // Use default color if level is null/undefined
+ return colors[levelLower] || colors.default;
+ };
+
+ useEffect(() => {
+ const flushBuffer = () => {
+ // Read the logs to flush into a local constant FIRST.
+ const logsToFlush = logBufferRef.current;
+
+ // If there's nothing to flush, do nothing.
+ if (logsToFlush.length === 0) {
+ return;
+ }
+
+ // Clear the ref so new logs can start buffering.
+ logBufferRef.current = [];
+
+ // Pass the updater function to setLogs.
+ setLogs((prevLogs) => [...prevLogs, ...logsToFlush]);
+ };
+
+ // Flush the buffer every 500ms
+ const flushInterval = setInterval(flushBuffer, 500);
+
+ return () => {
+ clearInterval(flushInterval);
+ flushBuffer(); // Flush any remaining logs on unmount
+ };
+ }, []);
+
+ const fetchAvailableLogs = async (showToast = false) => {
+ setIsRefreshing(true);
+ try {
+ const response = await fetch(`${API_URL}/logs`);
+ const data = await response.json();
+ setAvailableLogs(data.logs);
+ if (showToast) {
+ showSuccess(t("logViewer.logsRefreshed"));
+ }
+ return data.logs; // Return logs for initial load check
+ } catch (error) {
+ console.error("Error fetching log files:", error);
+ if (showToast) {
+ showError(t("logViewer.refreshFailed"));
+ }
+ return []; // Return empty on error
+ } finally {
+ setTimeout(() => setIsRefreshing(false), 500);
+ }
+ };
+
+ // NEW function to load the *entire* log
+ const fetchFullLogFile = async (logName) => {
+ if (!logName) {
+ showError(t("logViewer.noLogSelected"));
+ return;
+ }
+ setIsLoadingFullLog(true);
+ showInfo(t("logViewer.loadingFullLog", { name: logName }));
+ setAutoScroll(false); // Disable auto-scroll when loading full log
+ try {
+ const response = await fetch(`${API_URL}/logs/${logName}?tail=0`); // tail=0
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const data = await response.json();
+ const strippedContent = data.content.map((line) => line.trim());
+ // Parse all lines at once
+ const parsedLogs = strippedContent
+ .map(parseLogLine)
+ .filter((p) => p.raw !== null); // Filter out empty/invalid lines
+ setLogs(parsedLogs);
+ showSuccess(
+ t("logViewer.loadedFullLog", {
+ count: parsedLogs.length,
+ name: logName,
+ })
+ );
+ } catch (error) {
+ console.error("Error fetching full log:", error);
+ showError(t("logViewer.loadFailed", { name: logName }));
+ } finally {
+ setIsLoadingFullLog(false);
+ }
};
const gatherSupportZip = async () => {
setIsGatheringSupportZip(true);
- showInfo(t("logViewer.gatheringSupport", "Gathering support files..."));
+ showInfo(t("logViewer.gatheringSupport", "Gathering support files... This may take a moment."));
try {
- const response = await fetch(`${API_URL}/admin/support-zip`, { method: "POST" });
- if (!response.ok) throw new Error("Failed to generate zip");
+ const response = await fetch(`${API_URL}/admin/support-zip`, {
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ let errorMsg = `HTTP error! status: ${response.status}`;
+ try {
+ const errorData = await response.json();
+ errorMsg = errorData.detail || errorMsg;
+ } catch (e) {
+ // Response was not JSON
+ }
+ throw new Error(errorMsg);
+ }
+
+ // Get filename from Content-Disposition header
+ const contentDisposition = response.headers.get("content-disposition");
+ let downloadFilename = "posterizarr_support.zip"; // Default
+ if (contentDisposition) {
+ const filenameMatch = contentDisposition.match(/filename="([^"]+)"/i);
+ if (filenameMatch && filenameMatch[1]) {
+ downloadFilename = filenameMatch[1];
+ }
+ }
+
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
- a.download = "posterizarr_support.zip";
+ a.download = downloadFilename; // Use dynamic filename
+ document.body.appendChild(a);
a.click();
- showSuccess(t("logViewer.gatheringSupportSuccess"));
- } catch (error) { showError(t("logViewer.gatheringSupportFailed", { message: error.message })); }
- finally { setIsGatheringSupportZip(false); }
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ showSuccess(t("logViewer.gatheringSupportSuccess", "Support files downloaded."));
+
+ } catch (error) {
+ console.error("Error gathering support zip:", error);
+ showError(t("logViewer.gatheringSupportFailed", "Failed to gather support files: {{message}}", { message: error.message }));
+ } finally {
+ setIsGatheringSupportZip(false);
+ }
};
- const fetchAvailableLogs = useCallback(async (isManual = false) => {
- try {
- const response = await fetch('/api/logs');
- const data = await response.json();
- setAvailableLogs(data.logs || []);
- if (isManual) showSuccess(t("logViewer.logsRefreshed"));
- return data.logs || [];
- } catch (err) {
- console.error("Failed to fetch logs:", err);
- if (isManual) showError(t("logViewer.refreshFailed"));
- return [];
+ const disconnectWebSocket = () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
+ if (wsRef.current) {
+ wsRef.current.onopen = null;
+ wsRef.current.onclose = null;
+ wsRef.current.onerror = null;
+ wsRef.current.onmessage = null;
+ if (
+ wsRef.current.readyState === WebSocket.OPEN ||
+ wsRef.current.readyState === WebSocket.CONNECTING
+ ) {
+ wsRef.current.close();
+ }
+ wsRef.current = null;
}
- }, [t, showSuccess, showError]);
+ setConnected(false);
+ setIsReconnecting(false);
+ };
+
+ const connectWebSocket = (logFile) => {
+ if (!logFile) {
+ console.warn("WebSocket connection skipped: no log file selected.");
+ return;
+ }
+
+ if (
+ wsRef.current &&
+ (wsRef.current.readyState === WebSocket.OPEN ||
+ wsRef.current.readyState === WebSocket.CONNECTING)
+ ) {
+ if (currentLogFileRef.current === logFile) {
+ console.log(`Already connected to ${logFile}`);
+ return;
+ }
+ }
+
+ disconnectWebSocket();
- const fetchFullLogFile = async (filename) => {
- if (!filename) return;
- setIsLoading(true);
- showInfo(t("logViewer.loadingFullLog", { name: filename }));
try {
- const response = await fetch(`/api/logs/${encodeURIComponent(filename)}?tail=0`);
- if (!response.ok) throw new Error('Failed to fetch log file');
- const data = await response.json();
- const content = Array.isArray(data.content) ? data.content : data.content.split('\n');
- setLogs(content);
- showSuccess(t("logViewer.loadedFullLog", { count: content.length, name: filename }));
- } catch (err) { showError(t("logViewer.loadFailed", { name: filename })); }
- finally { setIsLoading(false); }
+ const wsURL = getWebSocketURL(logFile);
+ console.log(`Connecting to WebSocket: ${wsURL}`);
+ const ws = new WebSocket(wsURL);
+ currentLogFileRef.current = logFile;
+
+ ws.onopen = () => {
+ console.log(`WebSocket connected to ${logFile}`);
+ setLogs([]); // <-- FIX: Clear logs on new connection
+ setConnected(true);
+ setIsReconnecting(false);
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.type === "log") {
+ const parsedLine = parseLogLine(data.content);
+ if (parsedLine.raw) {
+ logBufferRef.current.push(parsedLine);
+ }
+ } else if (data.type === "log_file_changed") {
+ console.log(`Backend wants to switch to: ${data.log_file}`);
+ if (selectedLog === currentLogFileRef.current) {
+ console.log(`Accepting backend log switch to: ${data.log_file}`);
+ setSelectedLog(data.log_file);
+ currentLogFileRef.current = data.log_file;
+ showInfo(t("logViewer.switchedTo", { file: data.log_file }));
+ } else {
+ console.log(
+ `Ignoring backend log switch - user manually selected ${selectedLog}`
+ );
+ }
+ } else if (data.type === "error") {
+ console.error("WebSocket error message:", data.message);
+ showError(data.message);
+ }
+ } catch (error) {
+ console.error("Error parsing WebSocket message:", error);
+ }
+ };
+
+ ws.onerror = (error) => {
+ console.warn("WebSocket error:", error);
+ setConnected(false);
+ };
+
+ ws.onclose = (event) => {
+ console.log(" WebSocket closed:", event.code);
+ setConnected(false);
+ if (!event.wasClean) {
+ setIsReconnecting(true);
+ showError(t("logViewer.disconnected"));
+ reconnectTimeoutRef.current = setTimeout(() => {
+ console.log(`Reconnecting to ${currentLogFileRef.current}...`);
+ connectWebSocket(currentLogFileRef.current);
+ }, 2000);
+ }
+ };
+
+ wsRef.current = ws;
+ } catch (error) {
+ console.error("Failed to create WebSocket:", error);
+ setConnected(false);
+ setIsReconnecting(true);
+ reconnectTimeoutRef.current = setTimeout(() => {
+ connectWebSocket(logFile);
+ }, 3000);
+ }
};
- // --- Initial Mount ---
+ // Initial load effect
useEffect(() => {
const initialize = async () => {
- const logsData = await fetchAvailableLogs();
- const requestedLogFile = location.state?.logFile || "Scriptlog.log";
-
- // Flatten available logs for existence check
- const findLog = (items) => {
- for (const item of items) {
- if (item.type === 'file' && item.path === requestedLogFile) return item;
- if (item.children) {
- const found = findLog(item.children);
- if (found) return found;
- }
- }
- return null;
- };
+ // 1. Fetch all available logs
+ const logsData = await fetchAvailableLogs();
+
+ // 2. Determine which log to load
+ const requestedLogFile = location.state?.logFile || "Scriptlog.log";
+ const logExists = logsData.some((log) => log.name === requestedLogFile);
+
+ let logToLoad = null;
+
+ if (logExists) {
+ logToLoad = requestedLogFile;
+ } else if (requestedLogFile === "Scriptlog.log" && logsData.length > 0) {
+ // If Scriptlog.log was default but missing, pick the first available log
+ logToLoad = logsData[0].name;
+ showInfo(t("logViewer.scriptlogMissing", { fallback: logToLoad }));
+ } else if (logsData.length === 0) {
+ // No logs exist at all
+ showInfo(t("logViewer.noLogsFound"));
+ setLogs([]);
+ return; // Do not fetch or connect
+ } else if (logsData.length > 0) {
+ // Requested log doesn't exist, and it wasn't the default Scriptlog
+ showError(t("logViewer.loadFailed", { name: requestedLogFile }));
+ logToLoad = logsData[0].name; // Fallback to first log
+ } else {
+ // This case should be covered by logsData.length === 0, but as a safety net:
+ return; // No logs to load
+ }
+
+ // 3. Set the log, fetch content, and connect
+ setSelectedLog(logToLoad);
+ currentLogFileRef.current = logToLoad; // Manually set ref to prevent re-connect
+ // await fetchLogFile(logToLoad); // <-- REMOVED to prevent duplicates
+ connectWebSocket(logToLoad);
- const logExists = findLog(logsData);
- let logToLoad = logExists ? requestedLogFile : (logsData[0]?.path || "");
-
- if (logToLoad) setSelectedLog(logToLoad);
+ isInitialLoad.current = false; // Mark initial load as complete
};
initialize();
fetchStatus();
+
const statusInterval = setInterval(fetchStatus, 3000);
- return () => clearInterval(statusInterval);
- }, [fetchAvailableLogs, fetchStatus, location.state]);
- // --- WebSocket Connection ---
- useEffect(() => {
- if (!selectedLog) return;
-
- // Cleanup previous
- if (ws.current) ws.current.close();
- setLogs([]);
-
- const wsUrl = getWebSocketURL(selectedLog);
- ws.current = new WebSocket(wsUrl);
- currentLogFileRef.current = selectedLog;
-
- ws.current.onopen = () => setStatus('connected');
- ws.current.onmessage = (e) => {
- try {
- const data = JSON.parse(e.data);
- if (data.type === 'log') {
- setLogs(prev => [...prev, data.content].slice(-maxLines));
- } else if (data.type === "log_file_changed") {
- if (selectedLog === currentLogFileRef.current) {
- setSelectedLog(data.log_file);
- showInfo(t("logViewer.switchedTo", { file: data.log_file }));
- }
- }
- } catch {
- setLogs(prev => [...prev, e.data].slice(-maxLines));
- }
+ return () => {
+ clearInterval(statusInterval);
+ disconnectWebSocket();
};
- ws.current.onerror = () => setStatus('error');
- ws.current.onclose = () => setStatus('disconnected');
+ }, []); // Empty dependency array, runs only once on mount
+
+ // Effect to handle manual log selection changes
+ useEffect(() => {
+ if (isInitialLoad.current) {
+ // Don't run this on the very first load
+ return;
+ }
+
+ if (selectedLog && selectedLog !== currentLogFileRef.current) {
+ console.log(`Selected log changed to: ${selectedLog}`);
+ // fetchLogFile(selectedLog); // <-- REMOVED
+ // Reconnect websocket to the new log file
+ disconnectWebSocket();
+ setTimeout(() => {
+ connectWebSocket(selectedLog);
+ }, 300);
+ }
+ }, [selectedLog]);
+
- return () => ws.current?.close();
- }, [selectedLog, maxLines, t, showInfo]);
+ const filteredLogs = useMemo(() => {
+ const query = searchTerm.toLowerCase();
+
+ // 'logs' is now an array of { raw, level } objects
+ return logs.filter((parsed) => {
+ // We no longer need to call parseLogLine here!
+
+ const level = (parsed.level || "UNKNOWN").toUpperCase().trim();
+ const message = parsed.raw.toLowerCase(); // Filter against the raw line
+
+ let levelMatch = false;
+ if (parsed.level === null) {
+ // This is a raw line that didn't parse
+ levelMatch = !query || message.includes(query);
+ } else if (level === "INFO") {
+ levelMatch = levelFilters.INFO;
+ } else if (level === "WARNING" || level === "WARN") {
+ levelMatch = levelFilters.WARNING;
+ } else if (level === "ERROR") {
+ levelMatch = levelFilters.ERROR;
+ } else if (level === "DEBUG") {
+ levelMatch = levelFilters.DEBUG;
+ } else {
+ levelMatch = true; // Show other known levels by default
+ }
+
+ if (!levelMatch) return false;
+
+ // Search match is now the primary check
+ const searchMatch = !query || message.includes(query);
+
+ return searchMatch;
+ });
+ }, [logs, searchTerm, levelFilters]);
useEffect(() => {
- if (autoScroll && scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ if (autoScroll && logContainerRef.current) {
+ logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
- }, [logs, autoScroll]);
-
- const filteredTree = useMemo(() => {
- if (!searchTerm) return availableLogs;
- const filterItems = (items) => {
- return items.reduce((acc, item) => {
- if (item.type === "directory") {
- const filteredChildren = filterItems(item.children || []);
- if (item.name.toLowerCase().includes(searchTerm.toLowerCase()) || filteredChildren.length > 0) {
- acc.push({ ...item, children: filteredChildren });
- }
- } else if (item.name.toLowerCase().includes(searchTerm.toLowerCase())) acc.push(item);
- return acc;
- }, []);
+ }, [filteredLogs, autoScroll]);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setDropdownOpen(false);
+ }
};
- return filterItems(availableLogs);
- }, [availableLogs, searchTerm]);
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const clearLogs = () => {
+ setLogs([]);
+ showSuccess(t("logViewer.logsCleared"));
+ };
+
+ // UPDATED to download from state
+ const downloadLogs = () => {
+ if (!selectedLog) {
+ showError(t("logViewer.noLogSelected"));
+ return;
+ }
+
+ // Download the currently filtered logs from state
+ const logText = filteredLogs.map(p => p.raw).join("\n");
+ const blob = new Blob([logText], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+
+ const logNameWithoutExt = selectedLog.replace(/\.[^/.]+$/, "");
+ a.download = `${logNameWithoutExt}_${new Date()
+ .toISOString()
+ .replace(/[:.]/g, "-")}_(filtered).log`;
+
+ a.click();
+ URL.revokeObjectURL(url);
+
+ showSuccess(t("logViewer.downloaded", { count: filteredLogs.length }));
+ };
+
+ const getDisplayStatus = () => {
+ if (connected) {
+ return {
+ color: "bg-green-400",
+ icon: Wifi,
+ text: t("logViewer.status.live"),
+ ringColor: "ring-green-400/30",
+ };
+ } else if (isReconnecting) {
+ return {
+ color: "bg-yellow-400",
+ icon: Wifi,
+ text: t("logViewer.status.reconnecting"),
+ ringColor: "ring-yellow-400/30",
+ };
+ } else {
+ return {
+ color: "bg-red-400",
+ icon: WifiOff,
+ text: t("logViewer.status.disconnected"),
+ ringColor: "ring-red-400/30",
+ };
+ }
+ };
+
+ const displayStatus = getDisplayStatus();
+ const StatusIcon = displayStatus.icon;
return (
-
-
- {/* Header Area */}
-
-
- {/* Top Bar: Script Status & Support Buttons */}
-
-
- {scriptStatus.running && (
-
-
-
{t("logViewer.scriptRunning")}: {scriptStatus.current_mode}
-
- {isStopping ? : }
-
-
- )}
-
-
- {isGatheringSupportZip ? : }
- {t("logViewer.gatherSupport", "GATHER SUPPORT LOGS")}
-
+
+ {/* Header */}
+ {/* +++ MODIFIED: Added gap-4 and new button +++ */}
+
+ {/* Gather Support Logs Button */}
+
+ {isGatheringSupportZip ? (
+
+ ) : (
+
+ )}
+ {t("logViewer.gatherSupport", "Gather Support Logs")}
+
+
+ {/* Connection Status Badge */}
+
+
+
+ {(connected || isReconnecting) && (
+
+ )}
+
+
+
+
+ {displayStatus.text}
+
+
+
+ {/* +++ END MODIFICATION +++ */}
+
-
-
-
-
-
setSearchTerm(e.target.value)} />
+ {status.running && (
+
+
+
+
+
+
+ {t("logViewer.scriptRunning")}
+
+
+ {status.current_mode && (
+
+ {t("logViewer.mode")}: {status.current_mode}
+
+ )}
+ {t("logViewer.stopBeforeRunning")}
+
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ {t("logViewer.stopScript")}
+
+
+
+ )}
-
-
setDropdownOpen(!dropdownOpen)} className="w-full flex items-center justify-between px-6 py-2.5 bg-white/5 rounded-2xl border border-white/10 hover:border-theme-primary/50 group">
-
-
-
- {t("logViewer.activeStream", "Active Stream")}
- {selectedLog ? selectedLog.split('/').pop() : t("logViewer.selectLogFile", 'Select Log Sequence')}
-
+ {/* Controls Section */}
+
+
+ {/* Log Selector */}
+
+
+ {t("logViewer.selectLogFile")}
+
+
+
setDropdownOpen(!dropdownOpen)}
+ className="w-full px-4 py-3 bg-theme-bg border border-theme rounded-lg text-theme-text text-sm flex items-center justify-between hover:bg-theme-hover hover:border-theme-primary/50 transition-all shadow-sm"
+ >
+
+
+
+ {selectedLog || "Select a log"}
+
+ {selectedLog &&
+ availableLogs.find((l) => l.name === selectedLog) && (
+
+ (
+ {(
+ availableLogs.find((l) => l.name === selectedLog)
+ .size / 1024
+ ).toFixed(2)}{" "}
+ KB)
+
+ )}
-
+
+
{dropdownOpen && (
-
- {filteredTree.map(item =>
{ setSelectedLog(p); setDropdownOpen(false); }} />)}
+
+ {availableLogs.map((log) => (
+
{
+ console.log(`User selected log: ${log.name}`);
+ setSelectedLog(log.name);
+ setDropdownOpen(false);
+ }}
+ className={`w-full px-4 py-3 text-left text-sm transition-all ${
+ selectedLog === log.name
+ ? "bg-theme-primary text-white"
+ : "text-theme-text hover:bg-theme-hover hover:text-theme-primary"
+ }`}
+ >
+
+ {log.name}
+
+ {(log.size / 1024).toFixed(2)} KB
+
+
+
+ ))}
)}
-
-
-
- setLogFilter(e.target.value)} />
-
-
fetchAvailableLogs(true)} className="p-3 bg-white/5 hover:bg-theme-primary/20 rounded-2xl border border-white/10 text-theme-primary transition-all">
-
+ {/* Action Buttons */}
+
+ {/* Auto-scroll Toggle Switch */}
+
+
+ {t("logViewer.autoScroll")}
+
+
+
setAutoScroll(e.target.checked)}
+ className="peer sr-only"
+ />
+
+
+
+
+ {/* Refresh Button */}
+
fetchAvailableLogs(true)}
+ disabled={isRefreshing}
+ className="flex items-center gap-2 px-4 py-2 bg-theme-bg hover:bg-theme-hover border border-theme disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-all hover:scale-[1.02] shadow-sm"
+ >
+
+ {t("logViewer.refresh")}
-
fetchFullLogFile(selectedLog)} disabled={isLoading} className="p-3 bg-white/5 hover:bg-theme-primary/20 rounded-2xl border border-white/10 text-theme-primary transition-all">
- {isLoading ? : }
+
+ {/* Load Full Log Button */}
+ fetchFullLogFile(selectedLog)}
+ disabled={!selectedLog || isLoadingFullLog}
+ className="flex items-center gap-2 px-4 py-2 bg-theme-bg hover:bg-theme-hover border border-theme disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-all hover:scale-[1.02] shadow-sm"
+ >
+ {isLoadingFullLog ? (
+
+ ) : (
+
+ )}
+ {t("logViewer.loadFull")}
- {
- const filteredLogs = logs.filter(l => l.toLowerCase().includes(logFilter.toLowerCase()));
- const blob = new Blob([filteredLogs.join('\n')], {type: 'text/plain'});
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `${selectedLog.split('/').pop() || 'log'}_filtered.log`;
- a.click();
- showSuccess(t("logViewer.downloaded", { count: filteredLogs.length }));
- }} className="p-3 bg-white/5 hover:bg-theme-primary/20 rounded-2xl border border-white/10 text-theme-primary transition-all">
-
+
+ {/* Download Button */}
+
+
+ {t("logViewer.download")}
- { setLogs([]); showSuccess(t("logViewer.logsCleared")); }} className="p-3 bg-white/5 hover:bg-red-500/20 rounded-2xl border border-white/10 text-red-400 transition-all">
-
+
+ {/* +++ BUTTON REMOVED FROM HERE +++ */}
+
+ {/* Clear Button */}
+
+
+ {t("logViewer.clear")}
-
-
-
-
- {selectedLog && (
-
-
-
Path: {selectedLog}
+ {/* FILTER/SEARCH ROW */}
+
+ {/* Search Bar */}
+
+
+ {t("logViewer.searchLogs")}
+
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full pl-10 pr-10 py-2 bg-theme-bg border border-theme rounded-lg text-theme-text placeholder-theme-muted focus:outline-none focus:ring-1 focus:ring-theme-primary focus:border-theme-primary transition-all"
+ />
+ {searchTerm && (
+ setSearchTerm("")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-theme-muted hover:text-theme-text"
+ >
+
+
+ )}
- )}
+
+ {/* Level Filters */}
+
+
+ {t("logViewer.filterLevel")}
+
+
+
-
-
- {logs.length > 0 ? (
-
- {logs.filter(l => l.toLowerCase().includes(logFilter.toLowerCase())).map((line, i) => (
-
- ))}
+ {/* Log Display Section */}
+
+ {/* Log Container Header */}
+
+
+
+
+
+
+
+ {selectedLog || t("logViewer.noLogSelected")}
+
+
+ {selectedLog
+ ? t("logViewer.showingLast")
+ : t("logViewer.pleaseSelectLog")}
+
+
+
+
+
+ {t("logViewer.entries", { count: filteredLogs.length })}
+
+ {connected && (
+
+
+ {t("logViewer.status.live")}
+
+ )}
+ {isReconnecting && (
+
+
+ {t("logViewer.status.reconnecting")}
+
+ )}
+
+
+
+ {/* Terminal-Style Log Container */}
+
+ {filteredLogs.length === 0 ? (
+
+
+
+ {logs.length > 0 &&
+ (searchTerm || !Object.values(levelFilters).every((v) => v))
+ ? t("logViewer.noMatchingLogs")
+ : t("logViewer.noLogs")}
+
+
+ {logs.length > 0 &&
+ (searchTerm || !Object.values(levelFilters).every((v) => v))
+ ? t("logViewer.adjustFilters")
+ : availableLogs.length > 0
+ ? t("logViewer.startScript")
+ : t("logViewer.noLogsAvailable")}
+
) : (
-
-
-
{t("logViewer.noLogs", "System Idle")}
+
+ {filteredLogs.map((parsed, index) => { // 'parsed' is { raw, level }
+ // Get color based on parsed level
+ const logColor = getLogColor(parsed.level); // Use parsed.level
+
+ return (
+
+ {/* Render the raw line */}
+
{parsed.raw}
+
+ );
+ })}
)}
-
-
setAutoScroll(!autoScroll)} className={`flex items-center gap-3 px-6 py-3 rounded-2xl text-xs font-black tracking-widest transition-all duration-500 shadow-2xl border ${autoScroll ? 'bg-theme-primary text-white border-theme-primary scale-105' : 'bg-theme-card text-theme-muted border-white/10 grayscale'}`}>
-
- {autoScroll ? t("logViewer.on", 'AUTO-SCROLL ON') : t("logViewer.off", 'SCROLL LOCKED')}
-
-
-
setWrapText(!wrapText)} className="p-4 bg-theme-card/80 backdrop-blur-xl border border-white/10 rounded-2xl text-theme-muted hover:text-theme-primary transition-all">
-
setIsFullScreen(!isFullScreen)} className="p-4 bg-theme-card/80 backdrop-blur-xl border border-white/10 rounded-2xl text-theme-muted hover:text-theme-primary transition-all">{isFullScreen ? : }
+ {/* Footer */}
+
+
+
+ {t("logViewer.logEntries", { count: filteredLogs.length })}
+ {logs.length !== filteredLogs.length &&
+ ` (filtered from ${logs.length})`}
+
+ •
+
+ {t("logViewer.autoScrollStatus", {
+ status: autoScroll ? t("logViewer.on") : t("logViewer.off"),
+ })}
+
+ {connected && (
+
+
+
{t("logViewer.receivingUpdates")}
+
+ )}
+ {isReconnecting && (
+
+
+ {t("logViewer.status.reconnecting")}
+
+ )}
-
);
-};
+}
export default LogViewer;
\ No newline at end of file
From 6e63cf224fa6e8e163ad8b560fac88800689df21 Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Fri, 19 Dec 2025 07:14:46 +0100
Subject: [PATCH 11/18] Fix wrong folder
---
webui/backend/main.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/webui/backend/main.py b/webui/backend/main.py
index 58561b35..5afd50ec 100644
--- a/webui/backend/main.py
+++ b/webui/backend/main.py
@@ -922,9 +922,9 @@ def determine_media_type(filename: str, library_folder: str = None) -> str:
# Guess from folder name if DB lookup failed
if library_folder:
folder_lower = library_folder.lower()
- if any(k in folder_lower for k in ["show", "series", "tv", "serien", "anime"]):
+ if any(k in folder_lower for k in ["show", "series", "tv", "serien"]):
return "Show Background"
- if any(k in folder_lower for k in ["movie", "film", "kino", "4k"]):
+ if any(k in folder_lower for k in ["movie", "film", "kino"]):
return "Movie Background"
return "Background"
@@ -941,9 +941,9 @@ def determine_media_type(filename: str, library_folder: str = None) -> str:
# Guess from folder name if DB lookup failed
if library_folder:
folder_lower = library_folder.lower()
- if any(k in folder_lower for k in ["show", "series", "tv", "serien", "anime"]):
+ if any(k in folder_lower for k in ["show", "series", "tv", "serien"]):
return "Show"
- if any(k in folder_lower for k in ["movie", "film", "kino", "4k"]):
+ if any(k in folder_lower for k in ["movie", "film", "kino"]):
return "Movie"
# Default to Movie for unrecognized images
From b9a0d0b2c43ae755a336faf9467e10cbe45ff1b4 Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Fri, 19 Dec 2025 07:54:46 +0100
Subject: [PATCH 12/18] Update AssetReplacer.jsx
---
webui/frontend/src/components/AssetReplacer.jsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/webui/frontend/src/components/AssetReplacer.jsx b/webui/frontend/src/components/AssetReplacer.jsx
index 417477af..0030a024 100644
--- a/webui/frontend/src/components/AssetReplacer.jsx
+++ b/webui/frontend/src/components/AssetReplacer.jsx
@@ -278,8 +278,7 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
libName.includes("tv") ||
libName.includes("show") ||
libName.includes("series") ||
- libName.includes("serier") ||
- libName.includes("anime")
+ libName.includes("serier")
) {
mediaType = "tv";
}
From 64211ef407dc7ca194af2cd0a685e22362f9da3e Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Fri, 19 Dec 2025 08:12:27 +0100
Subject: [PATCH 13/18] Update AssetReplacer.jsx
---
webui/frontend/src/components/AssetReplacer.jsx | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/webui/frontend/src/components/AssetReplacer.jsx b/webui/frontend/src/components/AssetReplacer.jsx
index 0030a024..253b5646 100644
--- a/webui/frontend/src/components/AssetReplacer.jsx
+++ b/webui/frontend/src/components/AssetReplacer.jsx
@@ -93,7 +93,7 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
// Find library name - usually the top-level folder like "4K" or "TV"
for (let i = 0; i < pathSegments.length; i++) {
// Common library folder names
- if (pathSegments[i].match(/^(4K|TV|Movies|Series|anime)$/i)) {
+ if (pathSegments[i].match(/^(4K|TV|Movies|Series|Anime)$/i)) {
libraryName = pathSegments[i];
console.log(`Found library name: ${libraryName}`);
break;
@@ -267,8 +267,9 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
const dbType = (dbData?.Type || "").toLowerCase();
const libName = (libraryName || "").toLowerCase();
let mediaType = "movie"; // Default
-
- if (
+ if (dbType.includes("movie")) {
+ mediaType = "movie";
+ } else if (
dbType.includes("show") ||
backendAssetType.includes("show") ||
backendAssetType.includes("season") ||
@@ -278,7 +279,8 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
libName.includes("tv") ||
libName.includes("show") ||
libName.includes("series") ||
- libName.includes("serier")
+ libName.includes("serier") ||
+ libName.includes("anime")
) {
mediaType = "tv";
}
From ea9e6a577c3e0b8c2b6fac85f12fd4c9c55f74cc Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Fri, 19 Dec 2025 08:18:35 +0100
Subject: [PATCH 14/18] Update AssetReplacer.jsx
---
webui/frontend/src/components/AssetReplacer.jsx | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/webui/frontend/src/components/AssetReplacer.jsx b/webui/frontend/src/components/AssetReplacer.jsx
index 253b5646..79d017b8 100644
--- a/webui/frontend/src/components/AssetReplacer.jsx
+++ b/webui/frontend/src/components/AssetReplacer.jsx
@@ -266,11 +266,17 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
const backendAssetType = (asset.type || "").toLowerCase();
const dbType = (dbData?.Type || "").toLowerCase();
const libName = (libraryName || "").toLowerCase();
+
let mediaType = "movie"; // Default
- if (dbType.includes("movie")) {
+
+ // 1. Trust the database Type first if it exists
+ if (dbType === "movie") {
mediaType = "movie";
- } else if (
- dbType.includes("show") ||
+ } else if (dbType === "show" || dbType === "series") {
+ mediaType = "tv";
+ }
+ // 2. If no DB type, fallback to path/folder/library heuristics
+ else if (
backendAssetType.includes("show") ||
backendAssetType.includes("season") ||
backendAssetType.includes("episode") ||
@@ -279,8 +285,7 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
libName.includes("tv") ||
libName.includes("show") ||
libName.includes("series") ||
- libName.includes("serier") ||
- libName.includes("anime")
+ libName.includes("serier")
) {
mediaType = "tv";
}
From 93376cc864d32cd3f9e94ab48c79a24a8da3b2f6 Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Fri, 19 Dec 2025 08:21:06 +0100
Subject: [PATCH 15/18] Update AssetReplacer.jsx
---
.../frontend/src/components/AssetReplacer.jsx | 43 +++++++++++--------
1 file changed, 24 insertions(+), 19 deletions(-)
diff --git a/webui/frontend/src/components/AssetReplacer.jsx b/webui/frontend/src/components/AssetReplacer.jsx
index 79d017b8..2dc975bb 100644
--- a/webui/frontend/src/components/AssetReplacer.jsx
+++ b/webui/frontend/src/components/AssetReplacer.jsx
@@ -263,31 +263,36 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
}
// Determine mediaType
+ const dbType = (dbData?.Type || dbData?.library_type || "").toLowerCase();
const backendAssetType = (asset.type || "").toLowerCase();
- const dbType = (dbData?.Type || "").toLowerCase();
- const libName = (libraryName || "").toLowerCase();
+ const libName = (library_name || "").toLowerCase();
- let mediaType = "movie"; // Default
+ let mediaType = "movie"; // Default fallback
- // 1. Trust the database Type first if it exists
- if (dbType === "movie") {
+ // 1. STRICT DATABASE CHECK (Source of Trust)
+ if (dbType.includes("movie")) {
mediaType = "movie";
- } else if (dbType === "show" || dbType === "series") {
+ console.log("MediaType determined by DB: movie");
+ } else if (dbType.includes("show") || dbType.includes("series") || dbType.includes("tv")) {
mediaType = "tv";
+ console.log("MediaType determined by DB: tv");
}
- // 2. If no DB type, fallback to path/folder/library heuristics
- else if (
- backendAssetType.includes("show") ||
- backendAssetType.includes("season") ||
- backendAssetType.includes("episode") ||
- assetType === "season" ||
- assetType === "titlecard" ||
- libName.includes("tv") ||
- libName.includes("show") ||
- libName.includes("series") ||
- libName.includes("serier")
- ) {
- mediaType = "tv";
+ // 2. HEURISTIC FALLBACK (Only if DB type is missing or unknown)
+ else {
+ if (
+ backendAssetType.includes("show") ||
+ backendAssetType.includes("season") ||
+ backendAssetType.includes("episode") ||
+ assetType === "season" ||
+ assetType === "titlecard" ||
+ libName.includes("tv") ||
+ libName.includes("show") ||
+ libName.includes("series") ||
+ libName.includes("serier")
+ ) {
+ mediaType = "tv";
+ }
+ console.log(`MediaType determined by Heuristics: ${mediaType}`);
}
console.log(`Backend asset.type: '${backendAssetType}'`);
From 45da8e93fd7a1fadf782b121ae49138127309b4d Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Fri, 19 Dec 2025 08:33:58 +0100
Subject: [PATCH 16/18] Update AssetReplacer.jsx
---
.../frontend/src/components/AssetReplacer.jsx | 44 +++++++++----------
1 file changed, 22 insertions(+), 22 deletions(-)
diff --git a/webui/frontend/src/components/AssetReplacer.jsx b/webui/frontend/src/components/AssetReplacer.jsx
index 2dc975bb..4e90be2f 100644
--- a/webui/frontend/src/components/AssetReplacer.jsx
+++ b/webui/frontend/src/components/AssetReplacer.jsx
@@ -263,36 +263,36 @@ function AssetReplacer({ asset, onClose, onSuccess }) {
}
// Determine mediaType
- const dbType = (dbData?.Type || dbData?.library_type || "").toLowerCase();
const backendAssetType = (asset.type || "").toLowerCase();
- const libName = (library_name || "").toLowerCase();
+ const dbType = (dbData?.Type || "").toLowerCase();
+ const libName = (libraryName || "").toLowerCase(); // Corrected variable name
let mediaType = "movie"; // Default fallback
- // 1. STRICT DATABASE CHECK (Source of Trust)
+ // 1. STRICT DATABASE CHECK (Primary Source of Trust)
if (dbType.includes("movie")) {
mediaType = "movie";
- console.log("MediaType determined by DB: movie");
- } else if (dbType.includes("show") || dbType.includes("series") || dbType.includes("tv")) {
+ console.log("MediaType strictly determined by DB: movie");
+ } else if (dbType.includes("show") || dbType.includes("series")) {
mediaType = "tv";
- console.log("MediaType determined by DB: tv");
+ console.log("MediaType strictly determined by DB: tv");
}
- // 2. HEURISTIC FALLBACK (Only if DB type is missing or unknown)
- else {
- if (
- backendAssetType.includes("show") ||
- backendAssetType.includes("season") ||
- backendAssetType.includes("episode") ||
- assetType === "season" ||
- assetType === "titlecard" ||
- libName.includes("tv") ||
- libName.includes("show") ||
- libName.includes("series") ||
- libName.includes("serier")
- ) {
- mediaType = "tv";
- }
- console.log(`MediaType determined by Heuristics: ${mediaType}`);
+ // 2. HEURISTIC FALLBACK (Only used if DB data is missing/inconclusive)
+ else if (
+ backendAssetType.includes("show") ||
+ backendAssetType.includes("season") ||
+ backendAssetType.includes("episode") ||
+ assetType === "season" ||
+ assetType === "titlecard" ||
+ libName.includes("tv") ||
+ libName.includes("show") ||
+ libName.includes("series") ||
+ libName.includes("serier")
+ ) {
+ mediaType = "tv";
+ console.log(`MediaType determined by fallback heuristics: ${mediaType}`);
+ } else {
+ console.log(`Defaulting to: ${mediaType}`);
}
console.log(`Backend asset.type: '${backendAssetType}'`);
From cb68b5cc57457706faa6db84d6a9a55b41b1d7c1 Mon Sep 17 00:00:00 2001
From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com>
Date: Fri, 19 Dec 2025 09:08:47 +0100
Subject: [PATCH 17/18] slider fix
---
.../frontend/src/components/BackupAssets.jsx | 373 ++++--------------
webui/frontend/src/components/Gallery.jsx | 51 +--
.../frontend/src/components/ManualAssets.jsx | 37 +-
.../frontend/src/components/RecentAssets.jsx | 2 +-
.../frontend/src/components/SeasonGallery.jsx | 50 +--
.../src/components/TitleCardGallery.jsx | 49 +--
6 files changed, 171 insertions(+), 391 deletions(-)
diff --git a/webui/frontend/src/components/BackupAssets.jsx b/webui/frontend/src/components/BackupAssets.jsx
index d8a9a93b..6a8b717a 100644
--- a/webui/frontend/src/components/BackupAssets.jsx
+++ b/webui/frontend/src/components/BackupAssets.jsx
@@ -10,7 +10,6 @@ import {
ChevronLeft,
ChevronRight,
AlertCircle,
- FolderOpen,
Film,
Layers,
Tv,
@@ -106,15 +105,10 @@ const PaginationControls = ({ currentPage, totalPages, onPageChange }) => {
);
};
-// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
-// ++ MAIN BACKUP ASSETS COMPONENT
-// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
-
function BackupAssets() {
const { t } = useTranslation();
const { showSuccess, showError } = useToast();
- // State
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [libraries, setLibraries] = useState([]);
@@ -124,19 +118,14 @@ function BackupAssets() {
const [selectedAssets, setSelectedAssets] = useState(new Set());
const [bulkDeleteMode, setBulkDeleteMode] = useState(false);
- // Sorting
const [sortOrder, setSortOrder] = useState(() => localStorage.getItem("backup-assets-sort-order") || "name_asc");
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const sortDropdownRef = useRef(null);
- // Helper to encode path segments but keep slashes
- const safeEncodePath = (path) => {
- return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
- };
+ const safeEncodePath = (path) => path.split('/').map(segment => encodeURIComponent(segment)).join('/');
useEffect(() => localStorage.setItem("backup-assets-sort-order", sortOrder), [sortOrder]);
- // Click outside listener for sorting
useEffect(() => {
const handleClickOutside = (event) => {
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target)) {
@@ -161,21 +150,16 @@ function BackupAssets() {
return sorted;
};
- // Pagination
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(() => {
const saved = localStorage.getItem("backup-assets-items-per-page");
return saved ? parseInt(saved) : 50;
});
- // View Mode
const [viewMode, setViewMode] = useState(() => localStorage.getItem("backup-assets-view-mode") || "folder");
const [activeLibrary, setActiveLibrary] = useState("all");
+ const [currentPath, setCurrentPath] = useState([]);
- // Navigation (Folder View)
- const [currentPath, setCurrentPath] = useState([]); // [libraryName, folderName]
-
- // Image Grid Size
const [imageSize, setImageSize] = useState(() => {
const saved = localStorage.getItem("backup-assets-grid-size");
return saved ? parseInt(saved) : 5;
@@ -184,16 +168,6 @@ function BackupAssets() {
useEffect(() => localStorage.setItem("backup-assets-view-mode", viewMode), [viewMode]);
useEffect(() => localStorage.setItem("backup-assets-grid-size", imageSize), [imageSize]);
- const getGridClass = (size) => {
- const classes = {
- 2: "grid-cols-2", 3: "grid-cols-2 md:grid-cols-3", 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
- 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5", 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
- 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7", 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
- 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9", 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
- };
- return classes[size] || classes[5];
- };
-
const fetchAssets = async (showToast = false) => {
setLoading(true);
try {
@@ -215,7 +189,6 @@ function BackupAssets() {
useEffect(() => { fetchAssets(); }, []);
- // Selection Logic
const toggleAssetSelection = (assetPath) => {
setSelectedAssets((prev) => {
const newSet = new Set(prev);
@@ -226,24 +199,13 @@ function BackupAssets() {
const clearSelection = () => setSelectedAssets(new Set());
- // Delete Actions
const deleteAsset = async (assetPath, assetName) => {
if (!confirm(t("backupAssets.deleteConfirm", { name: assetName }))) return;
try {
- // FIX: Use safeEncodePath instead of encodeURIComponent
- const response = await fetch(
- `${API_URL}/backup-assets/${safeEncodePath(assetPath)}`,
- { method: "DELETE" }
- );
-
+ const response = await fetch(`${API_URL}/backup-assets/${safeEncodePath(assetPath)}`, { method: "DELETE" });
if (!response.ok) throw new Error("Failed to delete asset");
-
showSuccess(t("backupAssets.deleteSuccess", { name: assetName }));
-
- // Refresh logic
- const isLastItem = displayedGridAssets.length === 1 && currentPage > 1;
await fetchAssets();
- if (isLastItem) setCurrentPage(p => p - 1);
} catch (error) {
showError(error.message);
}
@@ -259,7 +221,6 @@ function BackupAssets() {
body: JSON.stringify({ paths: Array.from(selectedAssets) }),
});
if (!response.ok) throw new Error("Failed to delete assets");
-
showSuccess(t("backupAssets.bulkDeleteSuccess"));
clearSelection();
setBulkDeleteMode(false);
@@ -270,11 +231,7 @@ function BackupAssets() {
}
};
- // Helpers
- const formatTimestamp = (ts) => {
- if (!ts) return "Unknown";
- return new Date(ts * 1000).toLocaleString();
- };
+ const formatTimestamp = (ts) => ts ? new Date(ts * 1000).toLocaleString() : "Unknown";
const getAssetTypeIcon = (type) => {
switch (type) {
@@ -286,27 +243,22 @@ function BackupAssets() {
}
};
- const getAssetAspectRatio = (type) => {
- if (type === "background" || type === "titlecard") return "aspect-[16/9]";
- return "aspect-[2/3]";
- };
+ const getAssetAspectRatio = (type) => (type === "background" || type === "titlecard") ? "aspect-[16/9]" : "aspect-[2/3]";
- const matchesSearch = (asset, folder, library) => {
+ const matchesSearch = (asset, folder) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return asset.name.toLowerCase().includes(query) || folder.name.toLowerCase().includes(query);
};
- // Reset page when filters change
useEffect(() => setCurrentPage(1), [searchQuery, viewMode, activeLibrary, currentPath, itemsPerPage]);
- // Data Aggregation
const getAllAssets = () => {
const allAssets = [];
libraries.forEach((library) => {
library.folders.forEach((folder) => {
folder.assets.forEach((asset) => {
- if (matchesSearch(asset, folder, library)) {
+ if (matchesSearch(asset, folder)) {
if (activeLibrary === "all" || library.name === activeLibrary) {
allAssets.push({ ...asset, libraryName: library.name, folderName: folder.name });
}
@@ -317,21 +269,17 @@ function BackupAssets() {
return getSortedAssets(allAssets);
};
- // Navigation Logic
const navigateHome = () => { setCurrentPath([]); setSearchQuery(""); };
const navigateToLibrary = (lib) => { setCurrentPath([lib]); setSearchQuery(""); };
const navigateToFolder = (lib, folder) => { setCurrentPath([lib, folder]); setSearchQuery(""); };
const getCurrentViewData = () => {
if (currentPath.length === 0) {
- // List Libraries
return { type: "libraries", items: libraries.filter(l => l.name.toLowerCase().includes(searchQuery.toLowerCase())) };
} else if (currentPath.length === 1) {
- // List Folders in Library
const lib = libraries.find(l => l.name === currentPath[0]);
return lib ? { type: "folders", items: lib.folders.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())) } : { type: "folders", items: [] };
} else if (currentPath.length === 2) {
- // List Assets in Folder
const lib = libraries.find(l => l.name === currentPath[0]);
if (!lib) return { type: "assets", items: [] };
const folder = lib.folders.find(f => f.name === currentPath[1]);
@@ -343,20 +291,6 @@ function BackupAssets() {
const allGridAssets = viewMode === "grid" ? getAllAssets() : [];
const displayedGridAssets = allGridAssets.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
- // --- Grid View Pagination Logic for FOLDER view ---
- // When in folder view, if we are at level 2 (assets), we might need pagination
- const toggleSelectAllGrid = () => {
- const viewData = getCurrentViewData();
- if (viewData.type === "assets") {
- const displayedAssets = viewData.items.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
- if (selectedAssets.size === displayedAssets.length) {
- clearSelection();
- } else {
- setSelectedAssets(new Set(displayedAssets.map(a => a.path)));
- }
- }
- };
-
if (loading) return
;
if (error) return
{error}
;
@@ -383,240 +317,129 @@ function BackupAssets() {
{/* 2. GRID VIEW CONTENT */}
{viewMode === "grid" && (
-
- {/* Controls Bar */}
{t("backupAssets.filesTitle")}
-
-
-
-
{ setBulkDeleteMode(!bulkDeleteMode); if(bulkDeleteMode) clearSelection(); }}
- className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}
- >
- {bulkDeleteMode ? t("backupAssets.cancel") : t("backupAssets.select")}
-
-
- {/* Sort Dropdown */}
+
+
+ {t("dashboard.assets")}
+ {imageSize}
+
+
+
+
{ setBulkDeleteMode(!bulkDeleteMode); if(bulkDeleteMode) clearSelection(); }} className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}>{bulkDeleteMode ? t("backupAssets.cancel") : t("backupAssets.select")}
-
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-2 px-4 py-2 bg-theme-bg border border-theme-border rounded-lg">
- {t("backupAssets.sort")}
-
+
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-2 px-4 py-2 bg-theme-bg border border-theme-border rounded-lg"> {t("backupAssets.sort")}
{sortDropdownOpen && (
{["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => (
- { setSortOrder(opt); setSortDropdownOpen(false); }}
- className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}
- >
- {t(`common.sorting.${opt.replace('_', '')}`) || opt}
-
+ { setSortOrder(opt); setSortDropdownOpen(false); }} className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}>{t(`common.sorting.${opt.replace('_', '')}`) || opt}
))}
)}
-
fetchAssets(true)} className="flex items-center gap-2 px-4 py-2 bg-theme-bg border border-theme-border rounded-lg">
- {/* Library Filter */}
- setActiveLibrary("all")} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === "all" ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>
- {t("backupAssets.allLibraries")}
-
+ setActiveLibrary("all")} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === "all" ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>{t("backupAssets.allLibraries")}
{libraries.map(lib => (
- setActiveLibrary(lib.name)} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === lib.name ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>
- {lib.name}
-
+ setActiveLibrary(lib.name)} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === lib.name ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>{lib.name}
))}
- {/* Search Bar */}
- setSearchQuery(e.target.value)}
- className="w-full pl-12 pr-10 py-3 bg-theme-bg border border-theme-primary/50 rounded-lg focus:ring-2 focus:ring-theme-primary"
- />
+ setSearchQuery(e.target.value)} className="w-full pl-12 pr-10 py-3 bg-theme-bg border border-theme-primary/50 rounded-lg focus:ring-2 focus:ring-theme-primary" />
{searchQuery && setSearchQuery("")} className="absolute right-4 top-3"> }
- {/* Bulk Delete Bar */}
{bulkDeleteMode && (
-
+
setSelectedAssets(new Set(displayedGridAssets.map(a => a.path)))} className="px-4 py-2 bg-theme-hover rounded-lg text-sm">{t("backupAssets.selectPage")}
{t("backupAssets.clear")} ({selectedAssets.size})
{selectedAssets.size > 0 && (
-
- {t("backupAssets.delete")} ({selectedAssets.size})
-
+ {t("backupAssets.delete")} ({selectedAssets.size})
)}
)}
- {/* The Grid */}
- {displayedGridAssets.length === 0 ? (
-
{t("backupAssets.noBackups")}
- ) : (
-
- {displayedGridAssets.map(asset => (
-
- {bulkDeleteMode && (
-
- toggleAssetSelection(asset.path)}
- className="w-5 h-5 cursor-pointer accent-theme-primary"
- />
-
- )}
-
-
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}>
-
-
-
-
-
-
-
-
-
- {getAssetTypeIcon(asset.type)} {asset.type}
-
-
-
{asset.name}
-
{asset.libraryName}/{asset.folderName}
-
- {!bulkDeleteMode && (
-
- setSelectedImage(asset)} className="flex-1 bg-theme-hover py-1 rounded hover:text-theme-primary">{t("backupAssets.view")}
- deleteAsset(asset.path, asset.name)} className="flex-1 bg-red-500/10 text-red-500 py-1 rounded hover:bg-red-500 hover:text-white transition-colors">{t("backupAssets.delete")}
-
- )}
+
1024 ? `repeat(${imageSize}, minmax(0, 1fr))` : undefined }}>
+ {displayedGridAssets.map(asset => (
+
+
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}>
+
+
+
+
+
{asset.name}
+
{asset.libraryName}/{asset.folderName}
+ {!bulkDeleteMode && (
+
+ setSelectedImage(asset)} className="flex-1 bg-theme-hover py-1 rounded hover:text-theme-primary">{t("backupAssets.view")}
+ deleteAsset(asset.path, asset.name)} className="flex-1 bg-red-500/10 text-red-500 py-1 rounded hover:bg-red-500 hover:text-white transition-colors">{t("backupAssets.delete")}
+ )}
- ))}
-
- )}
-
- {allGridAssets.length > itemsPerPage && (
-
- )}
+
+ ))}
+
+
)}
{/* 3. FOLDER VIEW CONTENT */}
{viewMode === "folder" && (
- {/* Breadcrumb Navigation */}
-
- {t("backupAssets.title")}
-
+ {t("backupAssets.title")}
{currentPath.map((part, i) => (
- i === 0 ? navigateToLibrary(part) : null} className={`px-3 py-2 bg-theme-hover rounded-lg whitespace-nowrap ${i === currentPath.length -1 ? "font-semibold text-theme-primary" : ""}`}>
- {part}
-
+ i === 0 ? navigateToLibrary(part) : null} className={`px-3 py-2 bg-theme-hover rounded-lg whitespace-nowrap ${i === currentPath.length -1 ? "font-semibold text-theme-primary" : ""}`}>{part}
))}
- {/* Search & Controls Bar */}
- {/* Search */}
-
- {/* Slider (only at asset level) */}
{currentPath.length === 2 && (
-
+
+
+ {t("dashboard.assets")}
+ {imageSize}
+
+
+
)}
-
- {/* Bulk Selection (only at asset level) */}
{currentPath.length === 2 && (
- <>
-
setBulkDeleteMode(!bulkDeleteMode)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}>
- {bulkDeleteMode ? : }
- {bulkDeleteMode ? t("backupAssets.cancelSelection") : t("backupAssets.selectMultiple")}
-
- {bulkDeleteMode && selectedAssets.size > 0 && (
-
- {t("backupAssets.deleteSelected")} ({selectedAssets.size})
-
- )}
- >
+
setBulkDeleteMode(!bulkDeleteMode)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}>{bulkDeleteMode ? : } {bulkDeleteMode ? t("backupAssets.cancelSelection") : t("backupAssets.selectMultiple")}
)}
-
- {/* Sorting (All levels) */}
-
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-1.5 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme hover:border-theme-primary/50 rounded-lg text-theme-text text-sm font-medium">
-
- {sortOrder.includes("date") ? t("common.date") : t("common.name")}
-
+
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-1.5 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme hover:border-theme-primary/50 rounded-lg text-theme-text text-sm font-medium">{sortOrder.includes("date") ? t("common.date") : t("common.name")}
{sortDropdownOpen && (
-
- {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => (
- { setSortOrder(opt); setSortDropdownOpen(false); }} className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}>
- {t(`common.sorting.${opt.replace('_', '')}`) || opt}
-
- ))}
-
+ {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => (
+
{ setSortOrder(opt); setSortDropdownOpen(false); }} className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}>{t(`common.sorting.${opt.replace('_', '')}`) || opt}
+ ))}
)}
-
- {/* Refresh */}
-
fetchAssets(true)} className="flex items-center gap-2 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme-border hover:border-theme-primary/50 rounded-lg text-theme-text font-medium transition-all text-sm">
-
- Refresh
-
+
fetchAssets(true)} className="flex items-center gap-2 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme-border hover:border-theme-primary/50 rounded-lg text-theme-text font-medium transition-all text-sm">
- {/* Bulk Selection Actions Row (when active) */}
- {bulkDeleteMode && currentPath.length === 2 && (
-
-
-
- Select Page
-
-
-
- Clear ({selectedAssets.size})
-
-
- )}
-
{(() => {
const viewData = getCurrentViewData();
-
if (viewData.type === "libraries") {
return (
@@ -626,18 +449,11 @@ function BackupAssets() {
{lib.name}
-
-
Total: {lib.folders.reduce((sum, f) => sum + f.asset_count, 0)} assets
-
Posters: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'poster').length, 0)}
-
Backgrounds: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'background').length, 0)}
-
Seasons: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'season').length, 0)}
-
Episodes: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'titlecard').length, 0)}
-
+
{lib.folders.reduce((sum, f) => sum + f.asset_count, 0)} total assets
))}
- {viewData.items.length === 0 &&
{t("backupAssets.noBackups")}
}
);
} else if (viewData.type === "folders") {
@@ -647,43 +463,26 @@ function BackupAssets() {
navigateToFolder(currentPath[0], folder.name)} className="group relative bg-theme-card border border-theme-border rounded-lg p-4 transition-all text-left shadow-sm hover:shadow-md hover:border-theme-primary">
-
-
{folder.name}
-
{folder.asset_count} assets
-
+
{folder.name} {folder.asset_count} assets
))}
- {viewData.items.length === 0 &&
{t("backupAssets.noBackups")}
}
);
} else {
- // Assets in folder view
const pagedAssets = viewData.items.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
return (
-
+
1024 ? `repeat(${imageSize}, minmax(0, 1fr))` : undefined }}>
{pagedAssets.map(asset => (
- {bulkDeleteMode &&
toggleAssetSelection(asset.path)} className="w-5 h-5 accent-theme-primary cursor-pointer" />
}
-
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}>
- {!bulkDeleteMode &&
}
-
-
-
-
{asset.name}
- {!bulkDeleteMode && (
-
deleteAsset(asset.path, asset.name)} className="mt-2 w-full bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white py-1 rounded transition-colors flex items-center justify-center gap-1">
- {t("backupAssets.delete")}
-
- )}
+
))}
- {pagedAssets.length === 0 &&
{t("backupAssets.noBackups")}
}
);
@@ -692,52 +491,22 @@ function BackupAssets() {
)}
- {/* 4. IMAGE PREVIEW MODAL */}
{selectedImage && (
-
setSelectedImage(null)}>
+
setSelectedImage(null)}>
e.stopPropagation()}>
-
- {/* Image Area */}
-
-
-
-
- {/* Sidebar */}
+
-
-
{t("backupAssets.details")}
- setSelectedImage(null)} className="p-1 hover:bg-theme-hover rounded-full transition-colors">
-
-
+
{t("backupAssets.details")} setSelectedImage(null)}>
-
-
{t("backupAssets.nameLabel")}
-
{selectedImage.name}
-
-
-
-
{t("backupAssets.pathLabel")}
-
{selectedImage.path}
-
-
+
{t("backupAssets.nameLabel")} {selectedImage.name}
+
{t("backupAssets.pathLabel")} {selectedImage.path}
-
-
{t("backupAssets.sizeLabel")}
-
{(selectedImage.size / 1024).toFixed(2)} KB
-
-
-
{t("backupAssets.modifiedLabel")}
-
{formatTimestamp(selectedImage.modified).split(',')[0]}
-
+
{t("backupAssets.sizeLabel")} {(selectedImage.size / 1024).toFixed(2)} KB
+
{t("backupAssets.modifiedLabel")} {formatTimestamp(selectedImage.modified)}
-
-
- {t("backupAssets.download")}
-
-
{ if(confirm(t("backupAssets.deleteConfirm", { name: selectedImage.name }))) { deleteAsset(selectedImage.path, selectedImage.name); setSelectedImage(null); }}} className="flex items-center justify-center gap-2 btn bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white border border-red-500/20 py-2.5 rounded-lg transition-all">
- {t("backupAssets.delete")}
-
+
{t("backupAssets.download")}
+
{ if(confirm(t("backupAssets.deleteConfirm", { name: selectedImage.name }))) { deleteAsset(selectedImage.path, selectedImage.name); setSelectedImage(null); }}} className="flex items-center justify-center gap-2 bg-red-500/10 text-red-500 py-2.5 rounded-lg"> {t("backupAssets.delete")}
diff --git a/webui/frontend/src/components/Gallery.jsx b/webui/frontend/src/components/Gallery.jsx
index 9ab1b464..9ec38dbe 100644
--- a/webui/frontend/src/components/Gallery.jsx
+++ b/webui/frontend/src/components/Gallery.jsx
@@ -234,23 +234,6 @@ function Gallery() {
return saved ? parseInt(saved) : 5;
});
- // Grid column classes based on size (2-10 columns)
- // Mobile: 2 columns, Tablet (md): 3-4 columns depending on size, Desktop (lg): full size selection
- const getGridClass = (size) => {
- const classes = {
- 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
- 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
- 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
- 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
- 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
- 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
- 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
- 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
- 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
- };
- return classes[size] || classes[5];
- };
-
const fetchFolders = async (showNotification = false) => {
try {
const response = await fetch(`${API_URL}/assets-folders`);
@@ -656,12 +639,25 @@ function Gallery() {
{/* Controls - wrap on small screens */}
- {/* Compact Image Size Slider */}
-
+
+
+
+ {t("dashboard.assets")}
+
+ {/* Dynamic Badge */}
+
+ {imageSize}
+
+
+
+
+
{/* Select Mode Toggle */}
{activeFolder && images.length > 0 && (
-
+
1024
+ ? `repeat(${imageSize}, minmax(0, 1fr))`
+ : undefined
+ }}
+ >
{displayedImages.map((image, index) => (
{
- const classes = {
- 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
- 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
- 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
- 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
- 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
- 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
- 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
- 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
- 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
- };
- return classes[size] || classes[5];
- };
// Fetch manual assets
const fetchAssets = async (showToast = false) => {
@@ -959,7 +944,15 @@ function ManualAssets() {
-
+
1024
+ ? `repeat(${imageSize}, minmax(0, 1fr))`
+ : undefined
+ }}
+ >
{displayedGridAssets.map((asset) => (
)}
@@ -1477,7 +1470,15 @@ function ManualAssets() {
// Show assets grid
return (
<>
-
+
1024
+ ? `repeat(${imageSize}, minmax(0, 1fr))`
+ : undefined
+ }}
+ >
{displayedFolderAssets.map((asset) => (
{
const saved = localStorage.getItem("recent-assets-count");
const count = saved ? parseInt(saved) : 10;
- return Math.min(Math.max(count, 5), 10);
+ return Math.min(Math.max(count, 5), 20);
});
const fetchRecentAssets = async (silent = false) => {
diff --git a/webui/frontend/src/components/SeasonGallery.jsx b/webui/frontend/src/components/SeasonGallery.jsx
index 55e2a1b9..8cae4d16 100644
--- a/webui/frontend/src/components/SeasonGallery.jsx
+++ b/webui/frontend/src/components/SeasonGallery.jsx
@@ -235,22 +235,6 @@ function SeasonGallery() {
return saved ? parseInt(saved) : 5;
});
- // Grid column classes based on size (2-10 columns)
- // Mobile: 2 columns, Tablet (md): 3-4 columns depending on size, Desktop (lg): full size selection
- const getGridClass = (size) => {
- const classes = {
- 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
- 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
- 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
- 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
- 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
- 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
- 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
- 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
- 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
- };
- return classes[size] || classes[5];
- };
const fetchFolders = async (showNotification = false) => {
try {
@@ -626,12 +610,25 @@ function SeasonGallery() {
{/* Controls - wrap on small screens */}
- {/* Compact Image Size Slider */}
-
+
+
+
+ {t("dashboard.assets")}
+
+ {/* Dynamic Badge */}
+
+ {imageSize}
+
+
+
+
+
{/* Select Mode Toggle */}
{activeFolder && images.length > 0 && (
-
+
1024
+ ? `repeat(${imageSize}, minmax(0, 1fr))`
+ : undefined
+ }}
+ >
{displayedImages.map((image, index) => (
{
- const classes = {
- 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
- 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
- 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
- 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
- 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
- 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
- 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
- 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
- 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
- };
- return classes[size] || classes[5];
- };
const fetchFolders = async (showNotification = false) => {
try {
@@ -615,12 +599,25 @@ function TitleCardGallery() {
{/* Controls - wrap on small screens */}
- {/* Compact Image Size Slider */}
-
+
+
+
+ {t("dashboard.assets")}
+
+ {/* Dynamic Badge */}
+
+ {imageSize}
+
+
+
+
+
{/* Select Mode Toggle */}
{activeFolder && images.length > 0 && (
-
+
{displayedImages.map((image, index) => (
Date: Fri, 19 Dec 2025 09:29:36 +0100
Subject: [PATCH 18/18] revert
---
.../frontend/src/components/BackupAssets.jsx | 373 ++++++++++++++----
webui/frontend/src/components/Gallery.jsx | 51 ++-
.../frontend/src/components/ManualAssets.jsx | 37 +-
.../frontend/src/components/RecentAssets.jsx | 2 +-
.../frontend/src/components/SeasonGallery.jsx | 50 ++-
.../src/components/TitleCardGallery.jsx | 49 ++-
6 files changed, 391 insertions(+), 171 deletions(-)
diff --git a/webui/frontend/src/components/BackupAssets.jsx b/webui/frontend/src/components/BackupAssets.jsx
index 6a8b717a..d8a9a93b 100644
--- a/webui/frontend/src/components/BackupAssets.jsx
+++ b/webui/frontend/src/components/BackupAssets.jsx
@@ -10,6 +10,7 @@ import {
ChevronLeft,
ChevronRight,
AlertCircle,
+ FolderOpen,
Film,
Layers,
Tv,
@@ -105,10 +106,15 @@ const PaginationControls = ({ currentPage, totalPages, onPageChange }) => {
);
};
+// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+// ++ MAIN BACKUP ASSETS COMPONENT
+// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+
function BackupAssets() {
const { t } = useTranslation();
const { showSuccess, showError } = useToast();
+ // State
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [libraries, setLibraries] = useState([]);
@@ -118,14 +124,19 @@ function BackupAssets() {
const [selectedAssets, setSelectedAssets] = useState(new Set());
const [bulkDeleteMode, setBulkDeleteMode] = useState(false);
+ // Sorting
const [sortOrder, setSortOrder] = useState(() => localStorage.getItem("backup-assets-sort-order") || "name_asc");
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const sortDropdownRef = useRef(null);
- const safeEncodePath = (path) => path.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ // Helper to encode path segments but keep slashes
+ const safeEncodePath = (path) => {
+ return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ };
useEffect(() => localStorage.setItem("backup-assets-sort-order", sortOrder), [sortOrder]);
+ // Click outside listener for sorting
useEffect(() => {
const handleClickOutside = (event) => {
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target)) {
@@ -150,16 +161,21 @@ function BackupAssets() {
return sorted;
};
+ // Pagination
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(() => {
const saved = localStorage.getItem("backup-assets-items-per-page");
return saved ? parseInt(saved) : 50;
});
+ // View Mode
const [viewMode, setViewMode] = useState(() => localStorage.getItem("backup-assets-view-mode") || "folder");
const [activeLibrary, setActiveLibrary] = useState("all");
- const [currentPath, setCurrentPath] = useState([]);
+ // Navigation (Folder View)
+ const [currentPath, setCurrentPath] = useState([]); // [libraryName, folderName]
+
+ // Image Grid Size
const [imageSize, setImageSize] = useState(() => {
const saved = localStorage.getItem("backup-assets-grid-size");
return saved ? parseInt(saved) : 5;
@@ -168,6 +184,16 @@ function BackupAssets() {
useEffect(() => localStorage.setItem("backup-assets-view-mode", viewMode), [viewMode]);
useEffect(() => localStorage.setItem("backup-assets-grid-size", imageSize), [imageSize]);
+ const getGridClass = (size) => {
+ const classes = {
+ 2: "grid-cols-2", 3: "grid-cols-2 md:grid-cols-3", 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
+ 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5", 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
+ 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7", 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
+ 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9", 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
+ };
+ return classes[size] || classes[5];
+ };
+
const fetchAssets = async (showToast = false) => {
setLoading(true);
try {
@@ -189,6 +215,7 @@ function BackupAssets() {
useEffect(() => { fetchAssets(); }, []);
+ // Selection Logic
const toggleAssetSelection = (assetPath) => {
setSelectedAssets((prev) => {
const newSet = new Set(prev);
@@ -199,13 +226,24 @@ function BackupAssets() {
const clearSelection = () => setSelectedAssets(new Set());
+ // Delete Actions
const deleteAsset = async (assetPath, assetName) => {
if (!confirm(t("backupAssets.deleteConfirm", { name: assetName }))) return;
try {
- const response = await fetch(`${API_URL}/backup-assets/${safeEncodePath(assetPath)}`, { method: "DELETE" });
+ // FIX: Use safeEncodePath instead of encodeURIComponent
+ const response = await fetch(
+ `${API_URL}/backup-assets/${safeEncodePath(assetPath)}`,
+ { method: "DELETE" }
+ );
+
if (!response.ok) throw new Error("Failed to delete asset");
+
showSuccess(t("backupAssets.deleteSuccess", { name: assetName }));
+
+ // Refresh logic
+ const isLastItem = displayedGridAssets.length === 1 && currentPage > 1;
await fetchAssets();
+ if (isLastItem) setCurrentPage(p => p - 1);
} catch (error) {
showError(error.message);
}
@@ -221,6 +259,7 @@ function BackupAssets() {
body: JSON.stringify({ paths: Array.from(selectedAssets) }),
});
if (!response.ok) throw new Error("Failed to delete assets");
+
showSuccess(t("backupAssets.bulkDeleteSuccess"));
clearSelection();
setBulkDeleteMode(false);
@@ -231,7 +270,11 @@ function BackupAssets() {
}
};
- const formatTimestamp = (ts) => ts ? new Date(ts * 1000).toLocaleString() : "Unknown";
+ // Helpers
+ const formatTimestamp = (ts) => {
+ if (!ts) return "Unknown";
+ return new Date(ts * 1000).toLocaleString();
+ };
const getAssetTypeIcon = (type) => {
switch (type) {
@@ -243,22 +286,27 @@ function BackupAssets() {
}
};
- const getAssetAspectRatio = (type) => (type === "background" || type === "titlecard") ? "aspect-[16/9]" : "aspect-[2/3]";
+ const getAssetAspectRatio = (type) => {
+ if (type === "background" || type === "titlecard") return "aspect-[16/9]";
+ return "aspect-[2/3]";
+ };
- const matchesSearch = (asset, folder) => {
+ const matchesSearch = (asset, folder, library) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return asset.name.toLowerCase().includes(query) || folder.name.toLowerCase().includes(query);
};
+ // Reset page when filters change
useEffect(() => setCurrentPage(1), [searchQuery, viewMode, activeLibrary, currentPath, itemsPerPage]);
+ // Data Aggregation
const getAllAssets = () => {
const allAssets = [];
libraries.forEach((library) => {
library.folders.forEach((folder) => {
folder.assets.forEach((asset) => {
- if (matchesSearch(asset, folder)) {
+ if (matchesSearch(asset, folder, library)) {
if (activeLibrary === "all" || library.name === activeLibrary) {
allAssets.push({ ...asset, libraryName: library.name, folderName: folder.name });
}
@@ -269,17 +317,21 @@ function BackupAssets() {
return getSortedAssets(allAssets);
};
+ // Navigation Logic
const navigateHome = () => { setCurrentPath([]); setSearchQuery(""); };
const navigateToLibrary = (lib) => { setCurrentPath([lib]); setSearchQuery(""); };
const navigateToFolder = (lib, folder) => { setCurrentPath([lib, folder]); setSearchQuery(""); };
const getCurrentViewData = () => {
if (currentPath.length === 0) {
+ // List Libraries
return { type: "libraries", items: libraries.filter(l => l.name.toLowerCase().includes(searchQuery.toLowerCase())) };
} else if (currentPath.length === 1) {
+ // List Folders in Library
const lib = libraries.find(l => l.name === currentPath[0]);
return lib ? { type: "folders", items: lib.folders.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())) } : { type: "folders", items: [] };
} else if (currentPath.length === 2) {
+ // List Assets in Folder
const lib = libraries.find(l => l.name === currentPath[0]);
if (!lib) return { type: "assets", items: [] };
const folder = lib.folders.find(f => f.name === currentPath[1]);
@@ -291,6 +343,20 @@ function BackupAssets() {
const allGridAssets = viewMode === "grid" ? getAllAssets() : [];
const displayedGridAssets = allGridAssets.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
+ // --- Grid View Pagination Logic for FOLDER view ---
+ // When in folder view, if we are at level 2 (assets), we might need pagination
+ const toggleSelectAllGrid = () => {
+ const viewData = getCurrentViewData();
+ if (viewData.type === "assets") {
+ const displayedAssets = viewData.items.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
+ if (selectedAssets.size === displayedAssets.length) {
+ clearSelection();
+ } else {
+ setSelectedAssets(new Set(displayedAssets.map(a => a.path)));
+ }
+ }
+ };
+
if (loading) return
;
if (error) return
{error}
;
@@ -317,129 +383,240 @@ function BackupAssets() {
{/* 2. GRID VIEW CONTENT */}
{viewMode === "grid" && (
+
+ {/* Controls Bar */}
{t("backupAssets.filesTitle")}
+
-
-
- {t("dashboard.assets")}
- {imageSize}
-
-
-
-
{ setBulkDeleteMode(!bulkDeleteMode); if(bulkDeleteMode) clearSelection(); }} className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}>{bulkDeleteMode ? t("backupAssets.cancel") : t("backupAssets.select")}
+
+
+
{ setBulkDeleteMode(!bulkDeleteMode); if(bulkDeleteMode) clearSelection(); }}
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}
+ >
+ {bulkDeleteMode ? t("backupAssets.cancel") : t("backupAssets.select")}
+
+
+ {/* Sort Dropdown */}
-
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-2 px-4 py-2 bg-theme-bg border border-theme-border rounded-lg"> {t("backupAssets.sort")}
+
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-2 px-4 py-2 bg-theme-bg border border-theme-border rounded-lg">
+ {t("backupAssets.sort")}
+
{sortDropdownOpen && (
{["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => (
- { setSortOrder(opt); setSortDropdownOpen(false); }} className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}>{t(`common.sorting.${opt.replace('_', '')}`) || opt}
+ { setSortOrder(opt); setSortDropdownOpen(false); }}
+ className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}
+ >
+ {t(`common.sorting.${opt.replace('_', '')}`) || opt}
+
))}
)}
+
fetchAssets(true)} className="flex items-center gap-2 px-4 py-2 bg-theme-bg border border-theme-border rounded-lg">
+ {/* Library Filter */}
- setActiveLibrary("all")} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === "all" ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>{t("backupAssets.allLibraries")}
+ setActiveLibrary("all")} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === "all" ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>
+ {t("backupAssets.allLibraries")}
+
{libraries.map(lib => (
- setActiveLibrary(lib.name)} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === lib.name ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>{lib.name}
+ setActiveLibrary(lib.name)} className={`px-4 py-2 rounded-lg text-sm font-medium border ${activeLibrary === lib.name ? "bg-theme-primary text-white border-theme-primary" : "bg-theme-bg border-theme-border"}`}>
+ {lib.name}
+
))}
+ {/* Search Bar */}
- setSearchQuery(e.target.value)} className="w-full pl-12 pr-10 py-3 bg-theme-bg border border-theme-primary/50 rounded-lg focus:ring-2 focus:ring-theme-primary" />
+ setSearchQuery(e.target.value)}
+ className="w-full pl-12 pr-10 py-3 bg-theme-bg border border-theme-primary/50 rounded-lg focus:ring-2 focus:ring-theme-primary"
+ />
{searchQuery && setSearchQuery("")} className="absolute right-4 top-3"> }
+ {/* Bulk Delete Bar */}
{bulkDeleteMode && (
-
+
setSelectedAssets(new Set(displayedGridAssets.map(a => a.path)))} className="px-4 py-2 bg-theme-hover rounded-lg text-sm">{t("backupAssets.selectPage")}
{t("backupAssets.clear")} ({selectedAssets.size})
{selectedAssets.size > 0 && (
- {t("backupAssets.delete")} ({selectedAssets.size})
+
+ {t("backupAssets.delete")} ({selectedAssets.size})
+
)}
)}
-
1024 ? `repeat(${imageSize}, minmax(0, 1fr))` : undefined }}>
- {displayedGridAssets.map(asset => (
-
-
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}>
-
-
-
-
-
{asset.name}
-
{asset.libraryName}/{asset.folderName}
- {!bulkDeleteMode && (
-
-
setSelectedImage(asset)} className="flex-1 bg-theme-hover py-1 rounded hover:text-theme-primary">{t("backupAssets.view")}
-
deleteAsset(asset.path, asset.name)} className="flex-1 bg-red-500/10 text-red-500 py-1 rounded hover:bg-red-500 hover:text-white transition-colors">{t("backupAssets.delete")}
+ {/* The Grid */}
+ {displayedGridAssets.length === 0 ? (
+
{t("backupAssets.noBackups")}
+ ) : (
+
+ {displayedGridAssets.map(asset => (
+
+ {bulkDeleteMode && (
+
+ toggleAssetSelection(asset.path)}
+ className="w-5 h-5 cursor-pointer accent-theme-primary"
+ />
+
+ )}
+
+
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}>
+
+
+
+
+
+
+
+
+
+ {getAssetTypeIcon(asset.type)} {asset.type}
+
+
+
{asset.name}
+
{asset.libraryName}/{asset.folderName}
+
+ {!bulkDeleteMode && (
+
+ setSelectedImage(asset)} className="flex-1 bg-theme-hover py-1 rounded hover:text-theme-primary">{t("backupAssets.view")}
+ deleteAsset(asset.path, asset.name)} className="flex-1 bg-red-500/10 text-red-500 py-1 rounded hover:bg-red-500 hover:text-white transition-colors">{t("backupAssets.delete")}
+
+ )}
- )}
-
- ))}
-
-
+ ))}
+
+ )}
+
+ {allGridAssets.length > itemsPerPage && (
+
+ )}
)}
{/* 3. FOLDER VIEW CONTENT */}
{viewMode === "folder" && (
+ {/* Breadcrumb Navigation */}
- {t("backupAssets.title")}
+
+ {t("backupAssets.title")}
+
{currentPath.map((part, i) => (
- i === 0 ? navigateToLibrary(part) : null} className={`px-3 py-2 bg-theme-hover rounded-lg whitespace-nowrap ${i === currentPath.length -1 ? "font-semibold text-theme-primary" : ""}`}>{part}
+ i === 0 ? navigateToLibrary(part) : null} className={`px-3 py-2 bg-theme-hover rounded-lg whitespace-nowrap ${i === currentPath.length -1 ? "font-semibold text-theme-primary" : ""}`}>
+ {part}
+
))}
+ {/* Search & Controls Bar */}
+ {/* Search */}
+
+ {/* Slider (only at asset level) */}
{currentPath.length === 2 && (
-
-
- {t("dashboard.assets")}
- {imageSize}
-
-
-
+
)}
+
+ {/* Bulk Selection (only at asset level) */}
{currentPath.length === 2 && (
-
setBulkDeleteMode(!bulkDeleteMode)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}>{bulkDeleteMode ? : } {bulkDeleteMode ? t("backupAssets.cancelSelection") : t("backupAssets.selectMultiple")}
+ <>
+
setBulkDeleteMode(!bulkDeleteMode)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${bulkDeleteMode ? "bg-orange-600 text-white" : "bg-theme-primary text-white"}`}>
+ {bulkDeleteMode ? : }
+ {bulkDeleteMode ? t("backupAssets.cancelSelection") : t("backupAssets.selectMultiple")}
+
+ {bulkDeleteMode && selectedAssets.size > 0 && (
+
+ {t("backupAssets.deleteSelected")} ({selectedAssets.size})
+
+ )}
+ >
)}
+
+ {/* Sorting (All levels) */}
-
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-1.5 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme hover:border-theme-primary/50 rounded-lg text-theme-text text-sm font-medium">{sortOrder.includes("date") ? t("common.date") : t("common.name")}
+
setSortDropdownOpen(!sortDropdownOpen)} className="flex items-center gap-1.5 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme hover:border-theme-primary/50 rounded-lg text-theme-text text-sm font-medium">
+
+ {sortOrder.includes("date") ? t("common.date") : t("common.name")}
+
{sortDropdownOpen && (
- {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => (
-
{ setSortOrder(opt); setSortDropdownOpen(false); }} className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}>{t(`common.sorting.${opt.replace('_', '')}`) || opt}
- ))}
+
+ {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => (
+ { setSortOrder(opt); setSortDropdownOpen(false); }} className={`w-full text-left px-4 py-2 text-sm ${sortOrder === opt ? "bg-theme-primary/20 text-theme-primary" : "text-theme-text hover:bg-theme-hover"}`}>
+ {t(`common.sorting.${opt.replace('_', '')}`) || opt}
+
+ ))}
+
)}
-
fetchAssets(true)} className="flex items-center gap-2 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme-border hover:border-theme-primary/50 rounded-lg text-theme-text font-medium transition-all text-sm">
+
+ {/* Refresh */}
+
fetchAssets(true)} className="flex items-center gap-2 px-3 py-2 bg-theme-card hover:bg-theme-hover border border-theme-border hover:border-theme-primary/50 rounded-lg text-theme-text font-medium transition-all text-sm">
+
+ Refresh
+
+ {/* Bulk Selection Actions Row (when active) */}
+ {bulkDeleteMode && currentPath.length === 2 && (
+
+
+
+ Select Page
+
+
+
+ Clear ({selectedAssets.size})
+
+
+ )}
+
{(() => {
const viewData = getCurrentViewData();
+
if (viewData.type === "libraries") {
return (
@@ -449,11 +626,18 @@ function BackupAssets() {
{lib.name}
-
{lib.folders.reduce((sum, f) => sum + f.asset_count, 0)} total assets
+
+
Total: {lib.folders.reduce((sum, f) => sum + f.asset_count, 0)} assets
+
Posters: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'poster').length, 0)}
+
Backgrounds: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'background').length, 0)}
+
Seasons: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'season').length, 0)}
+
Episodes: {lib.folders.reduce((sum, f) => sum + f.assets.filter(a => a.type === 'titlecard').length, 0)}
+
))}
+ {viewData.items.length === 0 &&
{t("backupAssets.noBackups")}
}
);
} else if (viewData.type === "folders") {
@@ -463,26 +647,43 @@ function BackupAssets() {
navigateToFolder(currentPath[0], folder.name)} className="group relative bg-theme-card border border-theme-border rounded-lg p-4 transition-all text-left shadow-sm hover:shadow-md hover:border-theme-primary">
-
{folder.name} {folder.asset_count} assets
+
+
{folder.name}
+
{folder.asset_count} assets
+
))}
+ {viewData.items.length === 0 &&
{t("backupAssets.noBackups")}
}
);
} else {
+ // Assets in folder view
const pagedAssets = viewData.items.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
return (
-
1024 ? `repeat(${imageSize}, minmax(0, 1fr))` : undefined }}>
+
{pagedAssets.map(asset => (
+ {bulkDeleteMode &&
toggleAssetSelection(asset.path)} className="w-5 h-5 accent-theme-primary cursor-pointer" />
}
+
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}>
+ {!bulkDeleteMode &&
}
+
+
+
+
{asset.name}
+ {!bulkDeleteMode && (
+
deleteAsset(asset.path, asset.name)} className="mt-2 w-full bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white py-1 rounded transition-colors flex items-center justify-center gap-1">
+ {t("backupAssets.delete")}
+
+ )}
-
))}
+ {pagedAssets.length === 0 &&
{t("backupAssets.noBackups")}
}
);
@@ -491,22 +692,52 @@ function BackupAssets() {
)}
+ {/* 4. IMAGE PREVIEW MODAL */}
{selectedImage && (
-
setSelectedImage(null)}>
+
setSelectedImage(null)}>
e.stopPropagation()}>
-
+
+ {/* Image Area */}
+
+
+
+
+ {/* Sidebar */}
-
{t("backupAssets.details")} setSelectedImage(null)}>
+
+
{t("backupAssets.details")}
+ setSelectedImage(null)} className="p-1 hover:bg-theme-hover rounded-full transition-colors">
+
+
-
{t("backupAssets.nameLabel")} {selectedImage.name}
-
{t("backupAssets.pathLabel")} {selectedImage.path}
+
+
{t("backupAssets.nameLabel")}
+
{selectedImage.name}
+
+
+
+
{t("backupAssets.pathLabel")}
+
{selectedImage.path}
+
+
-
{t("backupAssets.sizeLabel")} {(selectedImage.size / 1024).toFixed(2)} KB
-
{t("backupAssets.modifiedLabel")} {formatTimestamp(selectedImage.modified)}
+
+
{t("backupAssets.sizeLabel")}
+
{(selectedImage.size / 1024).toFixed(2)} KB
+
+
+
{t("backupAssets.modifiedLabel")}
+
{formatTimestamp(selectedImage.modified).split(',')[0]}
+
+
-
{t("backupAssets.download")}
-
{ if(confirm(t("backupAssets.deleteConfirm", { name: selectedImage.name }))) { deleteAsset(selectedImage.path, selectedImage.name); setSelectedImage(null); }}} className="flex items-center justify-center gap-2 bg-red-500/10 text-red-500 py-2.5 rounded-lg"> {t("backupAssets.delete")}
+
+ {t("backupAssets.download")}
+
+
{ if(confirm(t("backupAssets.deleteConfirm", { name: selectedImage.name }))) { deleteAsset(selectedImage.path, selectedImage.name); setSelectedImage(null); }}} className="flex items-center justify-center gap-2 btn bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white border border-red-500/20 py-2.5 rounded-lg transition-all">
+ {t("backupAssets.delete")}
+
diff --git a/webui/frontend/src/components/Gallery.jsx b/webui/frontend/src/components/Gallery.jsx
index 9ec38dbe..9ab1b464 100644
--- a/webui/frontend/src/components/Gallery.jsx
+++ b/webui/frontend/src/components/Gallery.jsx
@@ -234,6 +234,23 @@ function Gallery() {
return saved ? parseInt(saved) : 5;
});
+ // Grid column classes based on size (2-10 columns)
+ // Mobile: 2 columns, Tablet (md): 3-4 columns depending on size, Desktop (lg): full size selection
+ const getGridClass = (size) => {
+ const classes = {
+ 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
+ 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
+ 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
+ 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
+ 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
+ 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
+ 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
+ 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
+ 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
+ };
+ return classes[size] || classes[5];
+ };
+
const fetchFolders = async (showNotification = false) => {
try {
const response = await fetch(`${API_URL}/assets-folders`);
@@ -639,25 +656,12 @@ function Gallery() {
{/* Controls - wrap on small screens */}
-
-
-
- {t("dashboard.assets")}
-
- {/* Dynamic Badge */}
-
- {imageSize}
-
-
-
-
-
+ {/* Compact Image Size Slider */}
+
{/* Select Mode Toggle */}
{activeFolder && images.length > 0 && (
-
1024
- ? `repeat(${imageSize}, minmax(0, 1fr))`
- : undefined
- }}
- >
+
{displayedImages.map((image, index) => (
{
+ const classes = {
+ 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
+ 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
+ 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
+ 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
+ 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
+ 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
+ 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
+ 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
+ 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
+ };
+ return classes[size] || classes[5];
+ };
// Fetch manual assets
const fetchAssets = async (showToast = false) => {
@@ -944,15 +959,7 @@ function ManualAssets() {
-
1024
- ? `repeat(${imageSize}, minmax(0, 1fr))`
- : undefined
- }}
- >
+
{displayedGridAssets.map((asset) => (
)}
@@ -1470,15 +1477,7 @@ function ManualAssets() {
// Show assets grid
return (
<>
-
1024
- ? `repeat(${imageSize}, minmax(0, 1fr))`
- : undefined
- }}
- >
+
{displayedFolderAssets.map((asset) => (
{
const saved = localStorage.getItem("recent-assets-count");
const count = saved ? parseInt(saved) : 10;
- return Math.min(Math.max(count, 5), 20);
+ return Math.min(Math.max(count, 5), 10);
});
const fetchRecentAssets = async (silent = false) => {
diff --git a/webui/frontend/src/components/SeasonGallery.jsx b/webui/frontend/src/components/SeasonGallery.jsx
index 8cae4d16..55e2a1b9 100644
--- a/webui/frontend/src/components/SeasonGallery.jsx
+++ b/webui/frontend/src/components/SeasonGallery.jsx
@@ -235,6 +235,22 @@ function SeasonGallery() {
return saved ? parseInt(saved) : 5;
});
+ // Grid column classes based on size (2-10 columns)
+ // Mobile: 2 columns, Tablet (md): 3-4 columns depending on size, Desktop (lg): full size selection
+ const getGridClass = (size) => {
+ const classes = {
+ 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
+ 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
+ 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
+ 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
+ 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
+ 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
+ 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
+ 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
+ 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
+ };
+ return classes[size] || classes[5];
+ };
const fetchFolders = async (showNotification = false) => {
try {
@@ -610,25 +626,12 @@ function SeasonGallery() {
{/* Controls - wrap on small screens */}
-
-
-
- {t("dashboard.assets")}
-
- {/* Dynamic Badge */}
-
- {imageSize}
-
-
-
-
-
+ {/* Compact Image Size Slider */}
+
{/* Select Mode Toggle */}
{activeFolder && images.length > 0 && (
-
1024
- ? `repeat(${imageSize}, minmax(0, 1fr))`
- : undefined
- }}
- >
+
{displayedImages.map((image, index) => (
{
+ const classes = {
+ 2: "grid-cols-2 md:grid-cols-2 lg:grid-cols-2",
+ 3: "grid-cols-2 md:grid-cols-3 lg:grid-cols-3",
+ 4: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
+ 5: "grid-cols-2 md:grid-cols-4 lg:grid-cols-5",
+ 6: "grid-cols-2 md:grid-cols-4 lg:grid-cols-6",
+ 7: "grid-cols-2 md:grid-cols-5 lg:grid-cols-7",
+ 8: "grid-cols-2 md:grid-cols-5 lg:grid-cols-8",
+ 9: "grid-cols-2 md:grid-cols-6 lg:grid-cols-9",
+ 10: "grid-cols-2 md:grid-cols-6 lg:grid-cols-10",
+ };
+ return classes[size] || classes[5];
+ };
const fetchFolders = async (showNotification = false) => {
try {
@@ -599,25 +615,12 @@ function TitleCardGallery() {
{/* Controls - wrap on small screens */}
-
-
-
- {t("dashboard.assets")}
-
- {/* Dynamic Badge */}
-
- {imageSize}
-
-
-
-
-
+ {/* Compact Image Size Slider */}
+
{/* Select Mode Toggle */}
{activeFolder && images.length > 0 && (
-
+
{displayedImages.map((image, index) => (