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 ProgressDisplay from './lib/components/ProgressDisplay.svelte';
|
||||||
import ResultsView from './lib/components/ResultsView.svelte';
|
import ResultsView from './lib/components/ResultsView.svelte';
|
||||||
import SettingsPanel from './lib/components/SettingsPanel.svelte';
|
import SettingsPanel from './lib/components/SettingsPanel.svelte';
|
||||||
|
import { sanitizeFolderName } from './lib/utils.js';
|
||||||
|
|
||||||
// Configuration constants
|
// Configuration constants
|
||||||
const STORAGE_KEY_PERSON = 'immich-timelapse-selected-person';
|
const STORAGE_KEY_PERSON = 'immich-timelapse-selected-person';
|
||||||
|
|
@ -53,36 +54,12 @@
|
||||||
let savedScrollPosition = $state(0);
|
let savedScrollPosition = $state(0);
|
||||||
|
|
||||||
// Output folder management
|
// Output folder management
|
||||||
let outputFolderRefreshKey = $state(0);
|
|
||||||
let outputFolders = $state([]);
|
let outputFolders = $state([]);
|
||||||
|
|
||||||
let isJobRunning = $derived(
|
let isJobRunning = $derived(
|
||||||
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
|
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
|
// Compute folder name from progress for video display
|
||||||
let completedFolderName = $derived(
|
let completedFolderName = $derived(
|
||||||
progress.person_name || progress.person_id
|
progress.person_name || progress.person_id
|
||||||
|
|
@ -90,8 +67,15 @@
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleFoldersLoaded(folders) {
|
async function loadOutputFolders() {
|
||||||
outputFolders = folders;
|
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) {
|
function handleFolderDeleted(folderName) {
|
||||||
|
|
@ -101,6 +85,8 @@
|
||||||
jobStatus = 'idle';
|
jobStatus = 'idle';
|
||||||
progress = { completed: 0, total: 0, message: '' };
|
progress = { completed: 0, total: 0, message: '' };
|
||||||
}
|
}
|
||||||
|
// Reload folder list after deletion
|
||||||
|
loadOutputFolders();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGallery(folder) {
|
function openGallery(folder) {
|
||||||
|
|
@ -175,7 +161,7 @@
|
||||||
// Refresh output folders when job finishes (was running before)
|
// Refresh output folders when job finishes (was running before)
|
||||||
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error') {
|
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error') {
|
||||||
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
|
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
|
||||||
outputFolderRefreshKey++;
|
loadOutputFolders();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -220,6 +206,17 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initial check will happen when connection is established
|
// 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(() => {
|
onDestroy(() => {
|
||||||
|
|
@ -280,9 +277,8 @@
|
||||||
<section class="output">
|
<section class="output">
|
||||||
<OutputManager
|
<OutputManager
|
||||||
disabled={isJobRunning}
|
disabled={isJobRunning}
|
||||||
|
folders={outputFolders}
|
||||||
onOpenGallery={openGallery}
|
onOpenGallery={openGallery}
|
||||||
refreshKey={outputFolderRefreshKey}
|
|
||||||
onFoldersLoaded={handleFoldersLoaded}
|
|
||||||
onFolderDeleted={handleFolderDeleted}
|
onFolderDeleted={handleFolderDeleted}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { formatSize } from '../utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
folderName,
|
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(() => {
|
onMount(() => {
|
||||||
loadImages();
|
loadImages();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,10 @@
|
||||||
<script>
|
<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);
|
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) {
|
async function deleteFolder(name) {
|
||||||
if (!confirm(`Delete output folder "${name}"? This cannot be undone.`)) {
|
if (!confirm(`Delete output folder "${name}"? This cannot be undone.`)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -45,10 +19,8 @@
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.message || 'Failed to delete folder');
|
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);
|
onFolderDeleted?.(name);
|
||||||
// Reload the list
|
|
||||||
await loadFolders();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -72,39 +44,20 @@
|
||||||
}
|
}
|
||||||
// Notify parent that all folders were deleted (null = all)
|
// Notify parent that all folders were deleted (null = all)
|
||||||
onFolderDeleted?.(null);
|
onFolderDeleted?.(null);
|
||||||
// Reload the list
|
|
||||||
await loadFolders();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
deleting = null;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="output-manager">
|
<div class="output-manager">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h3>Output Folders</h3>
|
<h3>Output Folders</h3>
|
||||||
<button type="button" class="refresh-btn" onclick={loadFolders} disabled={loading || disabled}>
|
|
||||||
{loading ? '...' : '↻'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if folders.length === 0}
|
||||||
<p class="status">Loading...</p>
|
|
||||||
{:else if error}
|
|
||||||
<p class="error">{error}</p>
|
|
||||||
{:else if folders.length === 0}
|
|
||||||
<p class="status empty">No output folders yet</p>
|
<p class="status empty">No output folders yet</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="folder-list">
|
<ul class="folder-list">
|
||||||
|
|
@ -192,28 +145,6 @@
|
||||||
margin: 0;
|
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 {
|
.status {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
|
|
@ -226,13 +157,6 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #dc2626;
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-list {
|
.folder-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { sanitizeFolderName, formatSize } from '../utils.js';
|
||||||
|
|
||||||
let { personId, personName, jobStatus, outputFolders = [], onupdate } = $props();
|
let { personId, personName, jobStatus, outputFolders = [], onupdate } = $props();
|
||||||
|
|
||||||
let dateFrom = $state('');
|
let dateFrom = $state('');
|
||||||
|
|
@ -10,29 +12,6 @@
|
||||||
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
|
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
|
// Check if an output folder already exists for this person
|
||||||
let existingFolder = $derived.by(() => {
|
let existingFolder = $derived.by(() => {
|
||||||
const expectedName = sanitizeFolderName(personName, personId);
|
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() {
|
function handleStartClick() {
|
||||||
if (existingFolder) {
|
if (existingFolder) {
|
||||||
const folder = 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