feat(frontend): add configurable app settings (file load limit, tag rule apply_to_existing)

- Add appSettings store (localStorage-backed) with two settings:
  fileLoadLimit (default 100) and tagRuleApplyToExisting (default false)
- Settings page: new Behaviour section with numeric input for files per
  page (10–500) and an on/off toggle for retroactive tag rule application
- files/+page.svelte: derive LIMIT from appSettings.fileLoadLimit so
  changes take effect immediately without reload
- TagRuleEditor: pass apply_to_existing from appSettings when activating
  a rule via PATCH (only sent on activation, not deactivation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Masahiko AMANO 2026-04-07 00:00:55 +03:00
parent 8cfcd39ab6
commit 012c6f9c48
4 changed files with 114 additions and 4 deletions

View File

@ -2,6 +2,7 @@
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types'; import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types';
import TagBadge from './TagBadge.svelte'; import TagBadge from './TagBadge.svelte';
import { appSettings } from '$lib/stores/appSettings';
interface Props { interface Props {
tagId: string; tagId: string;
@ -62,10 +63,11 @@
busy = true; busy = true;
error = ''; error = '';
const thenTagId = rule.then_tag_id!; const thenTagId = rule.then_tag_id!;
const activating = !rule.is_active;
try { try {
const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, { const body: Record<string, unknown> = { is_active: activating };
is_active: !rule.is_active, if (activating) body.apply_to_existing = $appSettings.tagRuleApplyToExisting;
}); const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, body);
onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r)); onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r));
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to update rule'; error = e instanceof ApiError ? e.message : 'Failed to update rule';

View File

@ -0,0 +1,28 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface AppSettings {
fileLoadLimit: number;
tagRuleApplyToExisting: boolean;
}
const DEFAULTS: AppSettings = {
fileLoadLimit: 100,
tagRuleApplyToExisting: false,
};
function load(): AppSettings {
if (!browser) return { ...DEFAULTS };
try {
const stored = JSON.parse(localStorage.getItem('app-settings') ?? 'null');
return stored ? { ...DEFAULTS, ...stored } : { ...DEFAULTS };
} catch {
return { ...DEFAULTS };
}
}
export const appSettings = writable<AppSettings>(load());
appSettings.subscribe((v) => {
if (browser) localStorage.setItem('app-settings', JSON.stringify(v));
});

View File

@ -15,6 +15,7 @@
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte'; import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
import { parseDslFilter } from '$lib/utils/dsl'; import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types'; import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
import { appSettings } from '$lib/stores/appSettings';
let uploader = $state<{ open: () => void } | undefined>(); let uploader = $state<{ open: () => void } | undefined>();
let confirmDeleteFiles = $state(false); let confirmDeleteFiles = $state(false);
@ -65,7 +66,7 @@
files = [file, ...files]; files = [file, ...files];
} }
const LIMIT = 50; let LIMIT = $derived($appSettings.fileLoadLimit);
const FILE_SORT_OPTIONS = [ const FILE_SORT_OPTIONS = [
{ value: 'created', label: 'Created' }, { value: 'created', label: 'Created' },

View File

@ -2,6 +2,7 @@
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { authStore } from '$lib/stores/auth'; import { authStore } from '$lib/stores/auth';
import { themeStore, toggleTheme } from '$lib/stores/theme'; import { themeStore, toggleTheme } from '$lib/stores/theme';
import { appSettings } from '$lib/stores/appSettings';
import type { User, Session, SessionList } from '$lib/api/types'; import type { User, Session, SessionList } from '$lib/api/types';
// ---- Profile ---- // ---- Profile ----
@ -242,6 +243,47 @@
</div> </div>
</section> </section>
<!-- ====== App settings ====== -->
<section class="card">
<h2 class="section-title">Behaviour</h2>
<div class="field">
<label class="label" for="file-limit">Files per page</label>
<p class="hint-text">How many files to load in one batch when scrolling the file list.</p>
<input
id="file-limit"
class="input input-narrow"
type="number"
min="10"
max="500"
step="1"
value={$appSettings.fileLoadLimit}
oninput={(e) => {
const v = parseInt((e.currentTarget as HTMLInputElement).value, 10);
if (!isNaN(v) && v >= 10 && v <= 500)
appSettings.update((s) => ({ ...s, fileLoadLimit: v }));
}}
/>
</div>
<div class="toggle-row">
<div>
<span class="toggle-label">Apply activated tag rules to existing files</span>
<p class="hint-text">When a tag rule is activated, automatically add the implied tag to all files that already have the source tag.</p>
</div>
<button
class="toggle"
class:on={$appSettings.tagRuleApplyToExisting}
role="switch"
aria-checked={$appSettings.tagRuleApplyToExisting}
aria-label="Apply activated tag rules to existing files"
onclick={() => appSettings.update((s) => ({ ...s, tagRuleApplyToExisting: !s.tagRuleApplyToExisting }))}
>
<span class="thumb"></span>
</button>
</div>
</section>
<!-- ====== Sessions ====== --> <!-- ====== Sessions ====== -->
<section class="card"> <section class="card">
<h2 class="section-title"> <h2 class="section-title">
@ -434,6 +476,43 @@
color: var(--color-accent); color: var(--color-accent);
} }
.input-narrow {
max-width: 100px;
}
/* On/off toggle switch */
.toggle {
flex-shrink: 0;
position: relative;
width: 42px;
height: 24px;
border-radius: 12px;
border: none;
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-primary));
cursor: pointer;
padding: 0;
transition: background-color 0.15s;
}
.toggle.on {
background-color: var(--color-accent);
}
.toggle .thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #fff;
transition: transform 0.15s;
}
.toggle.on .thumb {
transform: translateX(18px);
}
/* ---- PWA ---- */ /* ---- PWA ---- */
.hint-text { .hint-text {
font-size: 0.82rem; font-size: 0.82rem;