Frontend cleanup: moved some utility functions into a dedicated folder

This commit is contained in:
Arnaud_Cayrol 2026-02-08 10:19:02 +01:00
parent 4f4f64aab1
commit 79af84ad5e
5 changed files with 77 additions and 144 deletions

View file

@ -8,6 +8,7 @@
import ProgressDisplay from './lib/components/ProgressDisplay.svelte';
import ResultsView from './lib/components/ResultsView.svelte';
import SettingsPanel from './lib/components/SettingsPanel.svelte';
import { sanitizeFolderName } from './lib/utils.js';
// Configuration constants
const STORAGE_KEY_PERSON = 'immich-timelapse-selected-person';
@ -53,36 +54,12 @@
let savedScrollPosition = $state(0);
// Output folder management
let outputFolderRefreshKey = $state(0);
let outputFolders = $state([]);
let isJobRunning = $derived(
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
);
// Match backend's sanitize_folder_name logic for video path construction
// Must exactly replicate src/utils.rs sanitize_folder_name
function sanitizeFolderName(name, id) {
const base = name && name.trim() ? name : id;
// Remove accents by decomposing to NFD and filtering combining marks
const withoutAccents = base.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
// Convert to lowercase and replace unsafe characters with underscores
let sanitized = '';
for (const c of withoutAccents.toLowerCase()) {
if (/^[a-z0-9\-_]$/.test(c)) {
sanitized += c;
} else {
sanitized += '_';
}
}
// Trim whitespace and underscores, then limit length
sanitized = sanitized.replace(/^[\s_]+|[\s_]+$/g, '');
return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized;
}
// Compute folder name from progress for video display
let completedFolderName = $derived(
progress.person_name || progress.person_id
@ -90,8 +67,15 @@
: null
);
function handleFoldersLoaded(folders) {
outputFolders = folders;
async function loadOutputFolders() {
try {
const res = await fetch('/api/output');
if (res.ok) {
outputFolders = await res.json();
}
} catch (e) {
console.error('Failed to load output folders:', e);
}
}
function handleFolderDeleted(folderName) {
@ -101,6 +85,8 @@
jobStatus = 'idle';
progress = { completed: 0, total: 0, message: '' };
}
// Reload folder list after deletion
loadOutputFolders();
}
function openGallery(folder) {
@ -175,7 +161,7 @@
// Refresh output folders when job finishes (was running before)
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error') {
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
outputFolderRefreshKey++;
loadOutputFolders();
}
}
} catch (e) {
@ -220,6 +206,17 @@
onMount(() => {
// Initial check will happen when connection is established
// Load output folders initially and when connection is established
if (connectionOk) {
loadOutputFolders();
}
});
// Reload folders when connection is established
$effect(() => {
if (connectionOk) {
loadOutputFolders();
}
});
onDestroy(() => {
@ -280,9 +277,8 @@
<section class="output">
<OutputManager
disabled={isJobRunning}
folders={outputFolders}
onOpenGallery={openGallery}
refreshKey={outputFolderRefreshKey}
onFoldersLoaded={handleFoldersLoaded}
onFolderDeleted={handleFolderDeleted}
/>
</section>

View file

@ -1,5 +1,6 @@
<script>
import { onMount } from 'svelte';
import { formatSize } from '../utils.js';
let {
folderName,
@ -120,12 +121,6 @@
}
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
onMount(() => {
loadImages();
});

View file

@ -1,36 +1,10 @@
<script>
import { onMount } from 'svelte';
import { formatSize } from '../utils.js';
let { disabled = false, onOpenGallery, refreshKey = 0, onFoldersLoaded, onFolderDeleted } = $props();
let { disabled = false, folders = [], onOpenGallery, onFolderDeleted } = $props();
let folders = $state([]);
let loading = $state(true);
let error = $state(null);
let deleting = $state(null);
// Re-fetch when refreshKey changes
$effect(() => {
if (refreshKey > 0) {
loadFolders();
}
});
async function loadFolders() {
loading = true;
error = null;
try {
const res = await fetch('/api/output');
if (!res.ok) throw new Error('Failed to load output folders');
folders = await res.json();
// Notify parent about loaded folders (for existing folder warnings)
onFoldersLoaded?.(folders);
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function deleteFolder(name) {
if (!confirm(`Delete output folder "${name}"? This cannot be undone.`)) {
return;
@ -45,10 +19,8 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to delete folder');
}
// Notify parent that folder was deleted
// Notify parent that folder was deleted (parent will reload)
onFolderDeleted?.(name);
// Reload the list
await loadFolders();
} catch (e) {
alert(e.message);
} finally {
@ -72,39 +44,20 @@
}
// Notify parent that all folders were deleted (null = all)
onFolderDeleted?.(null);
// Reload the list
await loadFolders();
} catch (e) {
alert(e.message);
} finally {
deleting = null;
}
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
onMount(() => {
loadFolders();
});
</script>
<div class="output-manager">
<div class="header">
<h3>Output Folders</h3>
<button type="button" class="refresh-btn" onclick={loadFolders} disabled={loading || disabled}>
{loading ? '...' : '↻'}
</button>
</div>
{#if loading}
<p class="status">Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else if folders.length === 0}
{#if folders.length === 0}
<p class="status empty">No output folders yet</p>
{:else}
<ul class="folder-list">
@ -192,28 +145,6 @@
margin: 0;
}
.refresh-btn {
width: 2rem;
height: 2rem;
background: #252525;
border: none;
border-radius: 4px;
color: #888;
font-size: 1rem;
cursor: pointer;
transition: all 0.15s ease;
}
.refresh-btn:hover:not(:disabled) {
background: #333;
color: #e0e0e0;
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status {
font-size: 0.875rem;
color: #888;
@ -226,13 +157,6 @@
font-style: italic;
}
.error {
font-size: 0.875rem;
color: #dc2626;
text-align: center;
padding: 1rem;
}
.folder-list {
list-style: none;
margin: 0;

View file

@ -1,4 +1,6 @@
<script>
import { sanitizeFolderName, formatSize } from '../utils.js';
let { personId, personName, jobStatus, outputFolders = [], onupdate } = $props();
let dateFrom = $state('');
@ -10,29 +12,6 @@
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
);
// Match backend's sanitize_folder_name logic
// Must exactly replicate src/utils.rs sanitize_folder_name
function sanitizeFolderName(name, id) {
const base = name && name.trim() ? name : id;
// Remove accents by decomposing to NFD and filtering combining marks
const withoutAccents = base.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
// Convert to lowercase and replace unsafe characters with underscores
let sanitized = '';
for (const c of withoutAccents.toLowerCase()) {
if (/^[a-z0-9\-_]$/.test(c)) {
sanitized += c;
} else {
sanitized += '_';
}
}
// Trim whitespace and underscores, then limit length
sanitized = sanitized.replace(/^[\s_]+|[\s_]+$/g, '');
return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized;
}
// Check if an output folder already exists for this person
let existingFolder = $derived.by(() => {
const expectedName = sanitizeFolderName(personName, personId);
@ -110,12 +89,6 @@
}
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function handleStartClick() {
if (existingFolder) {
const folder = existingFolder;

45
frontend/src/lib/utils.js Normal file
View file

@ -0,0 +1,45 @@
/**
* Shared utility functions for the frontend.
* These must match backend logic where noted.
*/
/**
* Sanitize a folder name to match backend's sanitize_folder_name logic.
* IMPORTANT: Must exactly replicate src/utils.rs sanitize_folder_name
*
* @param {string} name - Person name
* @param {string} id - Person ID (fallback)
* @returns {string} Sanitized folder name
*/
export function sanitizeFolderName(name, id) {
const base = name && name.trim() ? name : id;
// Remove accents by decomposing to NFD and filtering combining marks
const withoutAccents = base.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
// Convert to lowercase and replace unsafe characters with underscores
let sanitized = '';
for (const c of withoutAccents.toLowerCase()) {
if (/^[a-z0-9\-_]$/.test(c)) {
sanitized += c;
} else {
sanitized += '_';
}
}
// Trim whitespace and underscores, then limit length
sanitized = sanitized.replace(/^[\s_]+|[\s_]+$/g, '');
return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized;
}
/**
* Format bytes to human-readable size string.
*
* @param {number} bytes - Size in bytes
* @returns {string} Formatted size (e.g., "1.2 MB")
*/
export function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}