Frontend cleanup: moved some utility functions into a dedicated folder
This commit is contained in:
parent
4f4f64aab1
commit
79af84ad5e
5 changed files with 77 additions and 144 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
45
frontend/src/lib/utils.js
Normal 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`;
|
||||
}
|
||||
Loading…
Reference in a new issue