From 0820b4b5480aae96aab60744efbdf8a24c6c9bdc Mon Sep 17 00:00:00 2001 From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:58:34 +0100 Subject: [PATCH 01/18] Dashboard enhancement --- Posterizarr.ps1 | 51 +---- Release.txt | 2 +- webui/frontend/src/components/Dashboard.jsx | 187 ++++++------------ .../frontend/src/components/RecentAssets.jsx | 27 ++- 4 files changed, 87 insertions(+), 180 deletions(-) diff --git a/Posterizarr.ps1 b/Posterizarr.ps1 index b1e7908a..b3a87853 100755 --- a/Posterizarr.ps1 +++ b/Posterizarr.ps1 @@ -53,7 +53,7 @@ for ($i = 0; $i -lt $ExtraArgs.Count; $i++) { } } -$CurrentScriptVersion = "2.2.13" +$CurrentScriptVersion = "2.2.14" $global:HeaderWritten = $false $ProgressPreference = 'SilentlyContinue' $env:PSMODULE_ANALYSIS_CACHE_PATH = $null @@ -5721,7 +5721,6 @@ function MassDownloadPlexArtwork { try { if ($($entry.RootFoldername)) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:posterurl = $null $global:ImageMagickError = $null $global:TMDBfallbackposterurl = $null @@ -5787,7 +5786,6 @@ function MassDownloadPlexArtwork { if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -5914,7 +5912,6 @@ function MassDownloadPlexArtwork { if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -6025,7 +6022,6 @@ function MassDownloadPlexArtwork { if ($($entry.RootFoldername)) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -6231,7 +6227,6 @@ function MassDownloadPlexArtwork { if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -6339,7 +6334,6 @@ function MassDownloadPlexArtwork { $global:PlexSeasonUrls = $entry.PlexSeasonUrls -split ',' for ($i = 0; $i -lt $global:seasonNames.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:posterurl = $null $global:seasontmp = $null $global:IsFallback = $null @@ -6475,7 +6469,6 @@ function MassDownloadPlexArtwork { # Loop through each episode foreach ($episode in $Episodedata) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -6510,7 +6503,6 @@ function MassDownloadPlexArtwork { $global:ImageMagickError = $null for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -10058,7 +10050,6 @@ Elseif ($Tautulli) { } Else { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:posterurl = $null $global:ImageMagickError = $null $global:TextlessPoster = $null @@ -10144,7 +10135,6 @@ Elseif ($Tautulli) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -10770,7 +10760,6 @@ Elseif ($Tautulli) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -11371,7 +11360,6 @@ Elseif ($Tautulli) { Else { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid @@ -12066,7 +12054,6 @@ Elseif ($Tautulli) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -12639,7 +12626,6 @@ Elseif ($Tautulli) { $global:PlexSeasonUrls = $entry.PlexSeasonUrls -split ',' for ($i = 0; $i -lt $global:seasonNames.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:seasontmp = $null $global:posterurl = $null @@ -13362,7 +13348,6 @@ Elseif ($Tautulli) { # Loop through each episode foreach ($episode in $Episodedata) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -13402,7 +13387,6 @@ Elseif ($Tautulli) { $global:ImageMagickError = $null for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -14037,7 +14021,6 @@ Elseif ($Tautulli) { Else { for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -15665,7 +15648,6 @@ Elseif ($ArrTrigger) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -16224,7 +16206,6 @@ Elseif ($ArrTrigger) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -16763,7 +16744,6 @@ Elseif ($ArrTrigger) { Else { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid @@ -17390,7 +17370,6 @@ Elseif ($ArrTrigger) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -17881,7 +17860,6 @@ Elseif ($ArrTrigger) { # Loop through each Season foreach ($season in $Episodedata) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:seasontmp = $null $global:IsFallback = $null @@ -18506,7 +18484,6 @@ Elseif ($ArrTrigger) { # Loop through each episode foreach ($episode in $Episodedata) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -18545,7 +18522,6 @@ Elseif ($ArrTrigger) { $global:ImageMagickError = $null for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -19058,7 +19034,6 @@ Elseif ($ArrTrigger) { Else { for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -20053,7 +20028,6 @@ Elseif ($ArrTrigger) { } Else { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:posterurl = $null $global:ImageMagickError = $null $global:TextlessPoster = $null @@ -20139,7 +20113,6 @@ Elseif ($ArrTrigger) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -20763,7 +20736,6 @@ Elseif ($ArrTrigger) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -21363,7 +21335,6 @@ Elseif ($ArrTrigger) { Else { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid @@ -22058,7 +22029,6 @@ Elseif ($ArrTrigger) { if (($FileTestOnTrigger -eq 'false') -or (-not $directoryHashtable.ContainsKey("$hashtestpath"))) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -22631,7 +22601,6 @@ Elseif ($ArrTrigger) { $global:PlexSeasonUrls = $entry.PlexSeasonUrls -split ',' for ($i = 0; $i -lt $global:seasonNames.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:seasontmp = $null $global:posterurl = $null @@ -23353,7 +23322,6 @@ Elseif ($ArrTrigger) { # Loop through each episode foreach ($episode in $Episodedata) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -23393,7 +23361,6 @@ Elseif ($ArrTrigger) { $global:ImageMagickError = $null for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -24028,7 +23995,6 @@ Elseif ($ArrTrigger) { Else { for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -26807,7 +26773,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -27367,7 +27332,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -27905,7 +27869,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer Else { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid @@ -28533,7 +28496,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -29023,7 +28985,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer # Loop through each Season foreach ($season in $Episodedata) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:seasontmp = $null $global:IsFallback = $null @@ -29662,7 +29623,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer # Loop through each episode foreach ($episode in $Episodedata) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -29701,7 +29661,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer $global:ImageMagickError = $null for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -30214,7 +30173,6 @@ Elseif ($OtherMediaServerUrl -and $OtherMediaServerApiKey -and $UseOtherMediaSer Else { for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null @@ -31570,7 +31528,6 @@ else { } Else { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:posterurl = $null $global:ImageMagickError = $null $global:TMDBfallbackposterurl = $null @@ -31657,7 +31614,6 @@ else { if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -32342,7 +32298,6 @@ else { if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -33007,7 +32962,6 @@ else { Else { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbsearched = $null $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid @@ -33774,7 +33728,6 @@ else { if (-not $directoryHashtable.ContainsKey("$hashtestpath")) { # Define Global Variables $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:tmdbid = $entry.tmdbid $global:tvdbid = $entry.tvdbid $global:imdbid = $entry.imdbid @@ -34409,7 +34362,6 @@ else { $global:PlexSeasonUrls = $entry.PlexSeasonUrls -split ',' for ($i = 0; $i -lt $global:seasonNames.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $Seasonpostersearchtext = $null $global:seasontmp = $null $global:TextlessPoster = $null @@ -35250,7 +35202,6 @@ else { $global:ImageMagickError = $null for ($i = 0; $i -lt $global:episode_numbers.Count; $i++) { $SkippingText = 'false' - $SkipAddOverlay = 'false' $global:AssetTextLang = $null $global:TMDBAssetTextLang = $null $global:FANARTAssetTextLang = $null diff --git a/Release.txt b/Release.txt index 3c3ae886..14f29673 100644 --- a/Release.txt +++ b/Release.txt @@ -1 +1 @@ -2.2.13 \ No newline at end of file +2.2.14 \ No newline at end of file diff --git a/webui/frontend/src/components/Dashboard.jsx b/webui/frontend/src/components/Dashboard.jsx index d8b757ef..ff94a482 100644 --- a/webui/frontend/src/components/Dashboard.jsx +++ b/webui/frontend/src/components/Dashboard.jsx @@ -45,7 +45,6 @@ const isDev = import.meta.env.DEV; const getLogFileForMode = (mode) => { const safeMode = (mode || "").toLowerCase(); - const logMapping = { testing: "Testinglog.log", manual: "Manuallog.log", @@ -156,7 +155,6 @@ function Dashboard() { const [draggedItem, setDraggedItem] = useState(null); const hasInitiallyLoaded = useRef(false); - // --- FETCH LOGIC --- const fetchDashboardData = async (silent = false) => { if (!silent) setIsRefreshing(true); try { @@ -244,7 +242,6 @@ function Dashboard() { } catch(e){} } - // --- WEBSOCKET LOGIC --- const connectDashboardWebSocket = () => { if (wsRef.current) return; try { @@ -281,7 +278,6 @@ function Dashboard() { if (wsRef.current) { wsRef.current.close(); wsRef.current = null; setWsConnected(false); } }; - // --- EFFECTS --- useEffect(() => { startLoading("dashboard"); fetchDashboardData(false); @@ -324,7 +320,6 @@ function Dashboard() { }); }, [allLogs, autoScroll]); - // Handle manual scroll detection to disable autoscroll useEffect(() => { const logContainer = logContainerRef.current; if (!logContainer) return; @@ -334,13 +329,10 @@ function Dashboard() { const currentScrollTop = scrollTop; const isAtBottom = scrollHeight - scrollTop - clientHeight < 20; - // Detect upward scroll (user scrolling up manually) if (currentScrollTop < lastScrollTop.current - 5) { userHasScrolled.current = true; if (autoScroll) setAutoScroll(false); } - - // If user scrolls to bottom manually, enable autoScroll again if (isAtBottom && !autoScroll) { setAutoScroll(true); userHasScrolled.current = false; @@ -363,7 +355,6 @@ function Dashboard() { } catch (error) { showError(error.message); } finally { setLoading(false); } }; - // --- UTILS --- const saveVisibilitySettings = (settings) => { setVisibleCards(settings); localStorage.setItem("dashboard_visible_cards", JSON.stringify(settings)); @@ -408,7 +399,6 @@ function Dashboard() { return { raw: cleanedLine }; }; - // Restored helper from Old Dashboard const LogLevel = ({ level }) => { const levelLower = (level || "").toLowerCase().trim(); const colors = { @@ -438,14 +428,13 @@ function Dashboard() { return colors[levelLower] || colors.default; }; - // --- UNIFIED CONTROL DECK COMPONENT --- const UnifiedControlDeck = () => { if (!visibleCards.statusCards) return null; return (
- {/* SECTION 1: SCRIPT ENGINE (The Core) */} + {/* SECTION 1: SCRIPT ENGINE */}
@@ -476,7 +465,7 @@ function Dashboard() {
- {/* SECTION 2: SCHEDULER (The Clock) */} + {/* SECTION 2: SCHEDULER */}
@@ -491,33 +480,64 @@ function Dashboard() {
- {schedulerStatus.enabled && schedulerStatus.next_run ? ( -
-
- {t("dashboard.controlDeck.nextRun")} - {new Date(schedulerStatus.next_run).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} -
- {/* Visual Timeline Bar */} -
-
-
+ {schedulerStatus.enabled && schedulerStatus.next_run ? (() => { + const now = new Date().getTime(); + const allTimes = schedulerStatus.schedules + .map(s => new Date(s.next_run).getTime()) + .sort((a, b) => a - b); + + const earliest = allTimes[0]; + const latest = allTimes[allTimes.length - 1]; + + let progress = 0; + if (latest !== earliest) { + progress = ((now - earliest) / (latest - earliest)) * 100; + progress = Math.max(0, Math.min(100, progress)); + } + + return ( +
+
+ {t("dashboard.controlDeck.nextRun")} + + {new Date(schedulerStatus.next_run).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} + +
+
+
+
+
+
+ Earliest + + {new Date(earliest).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} + +
+
+ Latest + + {new Date(latest).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} + +
+
-
- ) : ( -
+ ); + })() : ( +
{t("dashboard.controlDeck.noSchedules")} -
+
)} -
+
- {/* SECTION 3: SYSTEM (The Hardware - FULL DETAILS RESTORED) */} + {/* SECTION 3: SYSTEM */}
{t("dashboard.controlDeck.systemInfo")} - - {/* OS Platform & Version */}
{systemInfo.platform} @@ -534,9 +554,7 @@ function Dashboard() {
-
- {/* CPU Info */}
{t("dashboard.controlDeck.cpu")} @@ -547,8 +565,6 @@ function Dashboard() { {systemInfo.cpu_model || t("dashboard.controlDeck.genericCpu")}
- - {/* RAM Info - Full details */}
@@ -572,7 +588,7 @@ function Dashboard() {
- {/* SECTION 4: CONFIG (The Brain) */} + {/* SECTION 4: CONFIG */}
@@ -595,7 +611,6 @@ function Dashboard() {
-
{(version.local || version.remote) && (
@@ -623,21 +638,15 @@ function Dashboard() { ); }; - - // --- RENDER MAIN LAYOUT --- const renderDashboardCards = () => { const cardComponents = { statusCards: , - runtimeStats: visibleCards.runtimeStats && ( ), - recentAssets: visibleCards.recentAssets && ( ), - - // --- RESTORED OLD LOG VIEWER --- logViewer: visibleCards.logViewer && (
{t("dashboard.liveLogFeed")} - {wsConnected && (
@@ -661,7 +669,6 @@ function Dashboard() {
)}
- {status.running && status.active_log && allLogs.length > 0 && (

{ if (logContainerRef.current) { - logContainerRef.current.scrollTop = - logContainerRef.current.scrollHeight; + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; } }, 100); } @@ -702,11 +707,7 @@ function Dashboard() { className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ autoScroll ? "bg-theme-primary" : "bg-theme-hover" }`} - title={ - autoScroll - ? t("dashboard.autoScrollEnabled") - : t("dashboard.autoScrollDisabled") - } + title={autoScroll ? t("dashboard.autoScrollEnabled") : t("dashboard.autoScrollDisabled")} >

-
{status.running && allLogs && allLogs.length > 0 ? (
{allLogs.map((line, index) => { const parsed = parseLogLine(line); - - if (parsed.raw === null) { - return null; - } - + if (parsed.raw === null) return null; if (parsed.raw) { return (
); } - const logColor = getLogColor(parsed.level); - return (
- - [{parsed.timestamp}] - + [{parsed.timestamp}] - - |L.{parsed.lineNum}| - + |L.{parsed.lineNum}| {parsed.message}
); @@ -771,29 +755,20 @@ function Dashboard() { ) : (
-

- {t("dashboard.noLogs")} -

+

{t("dashboard.noLogs")}

- {status.running - ? t("dashboard.waitingForLogs") - : t("dashboard.startRunToSeeLogs")} + {status.running ? t("dashboard.waitingForLogs") : t("dashboard.startRunToSeeLogs")}

)}
-
- {t("dashboard.autoRefresh")}:{" "} - {wsConnected ? t("dashboard.live") : "1.5s"} + {t("dashboard.autoRefresh")}: {wsConnected ? t("dashboard.live") : "1.5s"} - {t("dashboard.lastEntries", { count: 25 })} •{" "} - {status.current_mode - ? getLogFileForMode(status.current_mode) - : status.active_log || t("dashboard.noActiveLog")} + {t("dashboard.lastEntries", { count: 25 })} • {status.current_mode ? getLogFileForMode(status.current_mode) : status.active_log || t("dashboard.noActiveLog")}
@@ -802,18 +777,14 @@ function Dashboard() { return cardOrder.map((key) => { const component = cardComponents[key]; - if (key === "statusCards" && status.running) { return ( {component} - {/* Active Run Banner - Placed specifically after statusCards so it appears above the logs in standard order */}
-
- -
+

{t("dashboard.banners.executionInProgress")}

{t("dashboard.banners.reviewLogs")}

@@ -829,21 +800,16 @@ function Dashboard() { return (
- {/* Hero Section */}
-
-

- {t("dashboard.title")} -

+

{t("dashboard.title")}

{t("dashboard.welcome")}. {t("dashboard.status")}: {status.running ? t("dashboard.controlDeck.active").toLowerCase() : t("dashboard.controlDeck.idle").toLowerCase()}.

-
{!status.running && ( @@ -851,19 +817,12 @@ function Dashboard() { {t("dashboard.runScript")} )} - -
- - {/* Already Running Alert */} {status.already_running_detected && (
@@ -876,15 +835,9 @@ function Dashboard() {
)} - - {/* Main Content Grid */} {renderDashboardCards()} - - setDeleteConfirm(false)} onConfirm={deleteRunningFile} title={t("dashboard.deleteConfirmTitle")} message={t("dashboard.deleteConfirmMessage")} confirmText={t("common.delete")} type="warning" /> - - {/* Settings Modal */} {showCardsModal && (
@@ -895,19 +848,10 @@ function Dashboard() {
{cardOrder.map((key, idx) => (
handleDragStart(e, idx)} onDragOver={(e) => handleDragOver(e, idx)} onDragEnd={handleDragEnd} className={`p-3 rounded-xl border border-theme bg-theme-card flex items-center justify-between hover:border-theme-primary/50 transition-all cursor-move ${draggedItem === idx ? "opacity-50" : ""}`}> -
- - {cardLabels[key]} -
+
{cardLabels[key]}
toggleCardVisibility(key)} className="w-5 h-5 accent-theme-primary rounded cursor-pointer" />
))} -
-
- {t("dashboard.hideScrollbars") || "Hide Scrollbars"} - -
-
@@ -918,5 +862,4 @@ function Dashboard() {
); } - export default Dashboard; \ No newline at end of file diff --git a/webui/frontend/src/components/RecentAssets.jsx b/webui/frontend/src/components/RecentAssets.jsx index f782d5f1..fa58bbb6 100644 --- a/webui/frontend/src/components/RecentAssets.jsx +++ b/webui/frontend/src/components/RecentAssets.jsx @@ -351,13 +351,26 @@ function RecentAssets({ refreshTrigger = 0 }) {
- + {/* Enhanced Slider with Badge */} +
+
+ + {t("dashboard.assets")} + + {/* Dynamic Badge */} + + {assetCount} + +
+ + +
+ {isOpen && item.children?.map(child => ( + + ))} +
+ ); + } + + // Render File + return ( + + ); +}; + function LogViewer() { const { t } = useTranslation(); const { showSuccess, showError, showInfo } = useToast(); @@ -794,29 +846,22 @@ function LogViewer() { {dropdownOpen && ( -
- {availableLogs.map((log) => ( - - ))} +
+ {availableLogs.length === 0 ? ( +
No logs found
+ ) : ( + availableLogs.map((item) => ( + { + setSelectedLog(path); + setDropdownOpen(false); + }} + /> + )) + )}
)}
From 8510133b585e0100182d5977ff8ccdfc35cde047 Mon Sep 17 00:00:00 2001 From: FSCorrupt <45659314+fscorrupt@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:23:42 +0100 Subject: [PATCH 09/18] Update log view --- webui/backend/main.py | 8 +- webui/frontend/src/components/LogViewer.jsx | 1284 +++++-------------- 2 files changed, 321 insertions(+), 971 deletions(-) diff --git a/webui/backend/main.py b/webui/backend/main.py index 1b84c2cf..489b4a46 100644 --- a/webui/backend/main.py +++ b/webui/backend/main.py @@ -7343,9 +7343,10 @@ 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 + # 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())): - rel_path = str(path.relative_to(root_path)) + # 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({ @@ -7354,7 +7355,7 @@ def get_directory_tree(root_path: Path, current_path: Path): "path": rel_path, "children": get_directory_tree(root_path, path) }) - elif path.suffix in ['.log', '.csv', '.json']: + elif path.suffix in ['.log', '.csv', '.json', '.txt']: items.append({ "name": path.name, "type": "file", @@ -7371,7 +7372,6 @@ async def list_logs(): if not LOGS_DIR.exists(): return {"logs": []} - # This will now return a nested structure instead of a flat list tree = get_directory_tree(LOGS_DIR, LOGS_DIR) return {"logs": tree} diff --git a/webui/frontend/src/components/LogViewer.jsx b/webui/frontend/src/components/LogViewer.jsx index 4e69302b..b33e8f47 100644 --- a/webui/frontend/src/components/LogViewer.jsx +++ b/webui/frontend/src/components/LogViewer.jsx @@ -1,1092 +1,442 @@ -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 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 { 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; -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 - - // 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], - })); - }; - - const allOn = Object.values(levelFilters).every((v) => v); - const allOff = Object.values(levelFilters).every((v) => !v); +// --- 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 toggleAll = () => { - const newState = !allOn; - setLevelFilters({ - INFO: newState, - WARNING: newState, - ERROR: newState, - DEBUG: newState, - }); + return parts.map((p, i) => ( + {p.text} + )); }; return ( -
- - - {filters.map(({ key, color, border }) => ( - - ))} +
+ + {parseAnsi(line)} +
); -}; -// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -// ++ END OF COMPONENT -// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +}); +// --- Sub-Component: LogTreeItem --- const LogTreeItem = ({ item, onSelect, selectedLog, level = 0 }) => { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(level < 1); const isSelected = selectedLog === item.path; - // Render Directory if (item.type === "directory") { return (
- {isOpen && item.children?.map(child => ( - - ))} +
+ {item.children?.map(child => ( + + ))} +
); } - // Render File return ( ); }; -function LogViewer() { +const LogViewer = () => { const { t } = useTranslation(); const { showSuccess, showError, showInfo } = useToast(); const location = useLocation(); + const [logs, setLogs] = useState([]); const [availableLogs, setAvailableLogs] = useState([]); - - const [selectedLog, setSelectedLog] = useState(null); // Set to null initially - + const [selectedLog, setSelectedLog] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isGatheringSupportZip, setIsGatheringSupportZip] = useState(false); + const [isStopping, setIsStopping] = useState(false); const [autoScroll, setAutoScroll] = useState(true); - const [connected, setConnected] = useState(false); - const [isReconnecting, setIsReconnecting] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [logFilter, setLogFilter] = useState(""); const [dropdownOpen, setDropdownOpen] = useState(false); - 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 [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 fetchStatus = async () => { + const fetchStatus = useCallback(async () => { try { const response = await fetch(`${API_URL}/status`); const data = await response.json(); - setStatus({ - running: data.running || false, - current_mode: data.current_mode || null, - }); - } catch (error) { - console.error("Error fetching status:", error); - } - }; + setScriptStatus({ running: data.running || false, current_mode: data.current_mode || null }); + } catch (error) { console.error("Error fetching status:", error); } + }, []); const stopScript = async () => { - setLoading(true); + setIsStopping(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 { - 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); - } + } else showError(t("logViewer.error", { message: data.message })); + } catch (error) { showError(t("logViewer.error", { message: error.message })); } + finally { setIsStopping(false); } }; const gatherSupportZip = async () => { setIsGatheringSupportZip(true); - showInfo(t("logViewer.gatheringSupport", "Gathering support files... This may take a moment.")); + showInfo(t("logViewer.gatheringSupport", "Gathering support files...")); try { - 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 response = await fetch(`${API_URL}/admin/support-zip`, { method: "POST" }); + if (!response.ok) throw new Error("Failed to generate zip"); const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = downloadFilename; // Use dynamic filename - document.body.appendChild(a); + a.download = "posterizarr_support.zip"; a.click(); - 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 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; - } - setConnected(false); - setIsReconnecting(false); + showSuccess(t("logViewer.gatheringSupportSuccess")); + } catch (error) { showError(t("logViewer.gatheringSupportFailed", { message: error.message })); } + finally { setIsGatheringSupportZip(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; - } + 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 []; } + }, [t, showSuccess, showError]); - disconnectWebSocket(); - + const fetchFullLogFile = async (filename) => { + if (!filename) return; + setIsLoading(true); + showInfo(t("logViewer.loadingFullLog", { name: filename })); try { - 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); - } + 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); } }; - // Initial load effect + // --- Initial Mount --- useEffect(() => { const initialize = async () => { - // 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 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; + }; - isInitialLoad.current = false; // Mark initial load as complete + const logExists = findLog(logsData); + let logToLoad = logExists ? requestedLogFile : (logsData[0]?.path || ""); + + if (logToLoad) setSelectedLog(logToLoad); }; initialize(); fetchStatus(); - const statusInterval = setInterval(fetchStatus, 3000); + return () => clearInterval(statusInterval); + }, [fetchAvailableLogs, fetchStatus, location.state]); - return () => { - clearInterval(statusInterval); - disconnectWebSocket(); - }; - }, []); // Empty dependency array, runs only once on mount - - // Effect to handle manual log selection changes + // --- WebSocket Connection --- 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]); - - - 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); + 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)); + } + }; + ws.current.onerror = () => setStatus('error'); + ws.current.onclose = () => setStatus('disconnected'); - return searchMatch; - }); - }, [logs, searchTerm, levelFilters]); + return () => ws.current?.close(); + }, [selectedLog, maxLines, t, showInfo]); useEffect(() => { - if (autoScroll && logContainerRef.current) { - logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [filteredLogs, autoScroll]); - - useEffect(() => { - const handleClickOutside = (event) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { - setDropdownOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); + }, [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; + }, []); }; - }, []); - - 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 filterItems(availableLogs); + }, [availableLogs, searchTerm]); return ( -
- {/* Header */} - {/* +++ MODIFIED: Added gap-4 and new button +++ */} -
- {/* Gather Support Logs Button */} - - - {/* Connection Status Badge */} -
-
-
- {(connected || isReconnecting) && ( -
- )} -
-
- - - {displayStatus.text} - -
-
-
- {/* +++ END MODIFICATION +++ */} - - - {status.running && ( -
-
-
-
- -
-
-

- {t("logViewer.scriptRunning")} -

-

- {status.current_mode && ( - - {t("logViewer.mode")}: {status.current_mode} - - )} - {t("logViewer.stopBeforeRunning")} -

-
+
+ + {/* Header Area */} +
+ + {/* Top Bar: Script Status & Support Buttons */} +
+
+ {scriptStatus.running && ( +
+ + {t("logViewer.scriptRunning")}: {scriptStatus.current_mode} + +
+ )}
- -
- )} - {/* Controls Section */} -
-
- {/* Log Selector */} -
- -
- - {dropdownOpen && ( -
- {availableLogs.length === 0 ? ( -
No logs found
- ) : ( - availableLogs.map((item) => ( - { - setSelectedLog(path); - setDropdownOpen(false); - }} - /> - )) - )} +
+ {filteredTree.map(item => { setSelectedLog(p); setDropdownOpen(false); }} />)}
)}
- {/* Action Buttons */} -
- {/* Auto-scroll Toggle Switch */} - - - {/* Refresh Button */} - - - {/* Load Full Log Button */} - - - {/* Download Button */} - - - {/* +++ BUTTON REMOVED FROM HERE +++ */} - - {/* Clear Button */} -
- {/* FILTER/SEARCH ROW */} -
- {/* Search Bar */} -
- -
- - 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 && ( - - )} +
+ + + + {selectedLog && ( +
+ + Path: {selectedLog}
-
- {/* Level Filters */} -
- - -
+ )}
- {/* 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")} -

+
+
+ {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"), - })} - +
+ +
+ +
- {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 ( +
+ + + {filters.map(({ key, color, border }) => ( -
- {item.children?.map(child => ( - - ))} -
-
- ); - } - - return ( - + ))} +
); }; +// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +// ++ 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} - -
- )} -
- +
+ {/* Header */} + {/* +++ MODIFIED: Added gap-4 and new button +++ */} +
+ {/* Gather Support Logs Button */} + + + {/* 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")} +

+
+ +
+
+ )} -
- + {dropdownOpen && ( -
- {filteredTree.map(item => { setSelectedLog(p); setDropdownOpen(false); }} />)} +
+ {availableLogs.map((log) => ( + + ))}
)}
-
-
- - setLogFilter(e.target.value)} /> -
- - - -
-
- - - - {selectedLog && ( -
- - Path: {selectedLog} + {/* FILTER/SEARCH ROW */} +
+ {/* Search Bar */} +
+ +
+ + 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 && ( + + )}
- )} +
+ {/* Level Filters */} +
+ + +
-
-
- {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}
+
+ ); + })}
)}
-
- -
- - + {/* 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")}

-
- - - - - {/* Sort Dropdown */} +
+
+ {t("dashboard.assets")} + {imageSize} +
+ +
+
- + {sortDropdownOpen && (
{["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => ( - + ))}
)}
-
- {/* Library Filter */}
- + {libraries.map(lib => ( - + ))}
- {/* 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 && }
- {/* Bulk Delete Bar */} {bulkDeleteMode && ( -
+
{selectedAssets.size > 0 && ( - + )}
)} - {/* 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)}> - {asset.name} -
- -
-
- -
-
- - {getAssetTypeIcon(asset.type)} {asset.type} - -
-

{asset.name}

-

{asset.libraryName}/{asset.folderName}

- - {!bulkDeleteMode && ( -
- - -
- )} +
1024 ? `repeat(${imageSize}, minmax(0, 1fr))` : undefined }}> + {displayedGridAssets.map(asset => ( +
+
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}> + {asset.name} +
+
+
+

{asset.name}

+

{asset.libraryName}/{asset.folderName}

+ {!bulkDeleteMode && ( +
+ +
+ )}
- ))} -
- )} - - {allGridAssets.length > itemsPerPage && ( - - )} +
+ ))} +
+
)} {/* 3. FOLDER VIEW CONTENT */} {viewMode === "folder" && (
- {/* Breadcrumb Navigation */}
- + {currentPath.map((part, i) => ( - + ))}
- {/* Search & Controls Bar */}
- {/* Search */}
- setSearchQuery(e.target.value)} - className="w-full pl-10 pr-10 py-2 bg-theme-bg border border-theme-primary/50 rounded-lg focus:outline-none focus:ring-2 focus:ring-theme-primary text-sm text-theme-text" - /> - {searchQuery && ( - - )} + setSearchQuery(e.target.value)} className="w-full pl-10 pr-10 py-2 bg-theme-bg border border-theme-primary/50 rounded-lg focus:outline-none focus:ring-2 focus:ring-theme-primary text-sm text-theme-text" /> + {searchQuery && }
- - {/* Slider (only at asset level) */} {currentPath.length === 2 && ( - +
+
+ {t("dashboard.assets")} + {imageSize} +
+ +
)} - - {/* Bulk Selection (only at asset level) */} {currentPath.length === 2 && ( - <> - - {bulkDeleteMode && selectedAssets.size > 0 && ( - - )} - + )} - - {/* Sorting (All levels) */}
- + {sortDropdownOpen && (
-
- {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => ( - - ))} -
+ {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => ( + + ))}
)}
- - {/* Refresh */} - +
- {/* Bulk Selection Actions Row (when active) */} - {bulkDeleteMode && currentPath.length === 2 && ( -
- - -
- )} - {(() => { 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() { ))} - {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)}> {asset.name} - {!bulkDeleteMode &&
} -
- -
-

{asset.name}

- {!bulkDeleteMode && ( - - )}
+

{asset.name}

))}
- {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 */} -
- {selectedImage.name} -
- - {/* Sidebar */} +
{selectedImage.name}
-
-

{t("backupAssets.details")}

- -
- +

{t("backupAssets.details")}

-
- -

{selectedImage.name}

-
- -
- -

{selectedImage.path}

-
- +

{selectedImage.name}

+

{selectedImage.path}

-
- -

{(selectedImage.size / 1024).toFixed(2)} KB

-
-
- -

{formatTimestamp(selectedImage.modified).split(',')[0]}

-
+

{(selectedImage.size / 1024).toFixed(2)} KB

+

{formatTimestamp(selectedImage.modified)}

-
- - {t("backupAssets.download")} - - + {t("backupAssets.download")} +
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} -
- -
- + + + + + {/* Sort Dropdown */}
- + {sortDropdownOpen && (
{["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => ( - + ))}
)}
+
+ {/* Library Filter */}
- + {libraries.map(lib => ( - + ))}
+ {/* 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 && }
+ {/* Bulk Delete Bar */} {bulkDeleteMode && ( -
+
{selectedAssets.size > 0 && ( - + )}
)} -
1024 ? `repeat(${imageSize}, minmax(0, 1fr))` : undefined }}> - {displayedGridAssets.map(asset => ( -
-
bulkDeleteMode ? toggleAssetSelection(asset.path) : setSelectedImage(asset)}> - {asset.name} -
-
-
-

{asset.name}

-

{asset.libraryName}/{asset.folderName}

- {!bulkDeleteMode && ( -
- - + {/* 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)}> + {asset.name} +
+ +
+
+ +
+
+ + {getAssetTypeIcon(asset.type)} {asset.type} + +
+

{asset.name}

+

{asset.libraryName}/{asset.folderName}

+ + {!bulkDeleteMode && ( +
+ + +
+ )}
- )}
-
- ))} -
- + ))} +
+ )} + + {allGridAssets.length > itemsPerPage && ( + + )}
)} {/* 3. FOLDER VIEW CONTENT */} {viewMode === "folder" && (
+ {/* Breadcrumb Navigation */}
- + {currentPath.map((part, i) => ( - + ))}
+ {/* Search & Controls Bar */}
+ {/* Search */}
- setSearchQuery(e.target.value)} className="w-full pl-10 pr-10 py-2 bg-theme-bg border border-theme-primary/50 rounded-lg focus:outline-none focus:ring-2 focus:ring-theme-primary text-sm text-theme-text" /> - {searchQuery && } + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-10 py-2 bg-theme-bg border border-theme-primary/50 rounded-lg focus:outline-none focus:ring-2 focus:ring-theme-primary text-sm text-theme-text" + /> + {searchQuery && ( + + )}
+ + {/* Slider (only at asset level) */} {currentPath.length === 2 && ( -
-
- {t("dashboard.assets")} - {imageSize} -
- -
+ )} + + {/* Bulk Selection (only at asset level) */} {currentPath.length === 2 && ( - + <> + + {bulkDeleteMode && selectedAssets.size > 0 && ( + + )} + )} + + {/* Sorting (All levels) */}
- + {sortDropdownOpen && (
- {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => ( - - ))} +
+ {["name_asc", "name_desc", "date_newest", "date_oldest"].map(opt => ( + + ))} +
)}
- + + {/* Refresh */} +
+ {/* Bulk Selection Actions Row (when active) */} + {bulkDeleteMode && currentPath.length === 2 && ( +
+ + +
+ )} + {(() => { 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() { ))} + {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)}> {asset.name} + {!bulkDeleteMode &&
} +
+ +
+

{asset.name}

+ {!bulkDeleteMode && ( + + )}
-

{asset.name}

))}
+ {pagedAssets.length === 0 &&
{t("backupAssets.noBackups")}
}
); @@ -491,22 +692,52 @@ function BackupAssets() {
)} + {/* 4. IMAGE PREVIEW MODAL */} {selectedImage && ( -
setSelectedImage(null)}> +
setSelectedImage(null)}>
e.stopPropagation()}> -
{selectedImage.name}
+ + {/* Image Area */} +
+ {selectedImage.name} +
+ + {/* Sidebar */}
-

{t("backupAssets.details")}

+
+

{t("backupAssets.details")}

+ +
+
-

{selectedImage.name}

-

{selectedImage.path}

+
+ +

{selectedImage.name}

+
+ +
+ +

{selectedImage.path}

+
+
-

{(selectedImage.size / 1024).toFixed(2)} KB

-

{formatTimestamp(selectedImage.modified)}

+
+ +

{(selectedImage.size / 1024).toFixed(2)} KB

+
+
+ +

{formatTimestamp(selectedImage.modified).split(',')[0]}

+
+
- {t("backupAssets.download")} - + + {t("backupAssets.download")} + +
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) => (