298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
"use client";
|
|
import { useSettings } from "./SettingsProvider";
|
|
import { PurgeOrphansButton } from "./PurgeOrphansButton";
|
|
import { ReorganizeButton } from "./ReorganizeButton";
|
|
import { RegenThumbnailsButton } from "./RegenThumbnailsButton";
|
|
import { UndersizedCoversButton } from "./UndersizedCoversButton";
|
|
import { NearDupesButton } from "./NearDupesButton";
|
|
import { ReparseCodesButton } from "./ReparseCodesButton";
|
|
import { ClearCacheButton } from "./ClearCacheButton";
|
|
import { BackupButtons } from "./BackupButtons";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/** Display group: grid columns + fade transitions. */
|
|
export function DisplayGroup() {
|
|
const { settings, set } = useSettings();
|
|
return (
|
|
<div className="space-y-1">
|
|
<SliderRow
|
|
label="Grid Columns (Landscape)"
|
|
description="Number of cover columns shown when the library is in L (landscape / full cover) view."
|
|
min={2}
|
|
max={4}
|
|
step={1}
|
|
value={settings.gridColumns}
|
|
onChange={(v) => set("gridColumns", v)}
|
|
format={(v) => `${v} per row`}
|
|
/>
|
|
<SliderRow
|
|
label="Grid Columns (Portrait)"
|
|
description="Number of cover columns shown when the library is in P (portrait / front-only) view."
|
|
min={4}
|
|
max={10}
|
|
step={1}
|
|
value={settings.gridColumnsPortrait}
|
|
onChange={(v) => set("gridColumnsPortrait", v)}
|
|
format={(v) => `${v} per row`}
|
|
/>
|
|
<SliderRow
|
|
label="Items Per Page"
|
|
description="Cover grid page size. Pagination + infinite scroll fetch this many at a time."
|
|
min={25}
|
|
max={500}
|
|
step={25}
|
|
value={settings.coverPageSize}
|
|
onChange={(v) => set("coverPageSize", v)}
|
|
format={(v) => `${v} per page`}
|
|
/>
|
|
<SegmentedRow
|
|
label="Pagination Behavior"
|
|
description={
|
|
settings.paginationMode === "url"
|
|
? "Prev / Next / Jump always pushes a new URL and remounts the grid. Predictable; small flash on each click."
|
|
: "Prev / Next scrolls within the loaded buffer. Forward jumps prefetch missing pages on the fly. Backward across the SSR anchor falls back to URL nav."
|
|
}
|
|
value={settings.paginationMode}
|
|
options={[
|
|
{ value: "url", label: "URL" },
|
|
{ value: "scroll", label: "Scroll" },
|
|
]}
|
|
onChange={(v) => set("paginationMode", v as "url" | "scroll")}
|
|
/>
|
|
<ToggleRow
|
|
label="Fade Transitions"
|
|
description="Fade in pages and image details when navigating."
|
|
value={settings.fadeTransitions}
|
|
onChange={(v) => set("fadeTransitions", v)}
|
|
/>
|
|
{settings.fadeTransitions && (
|
|
<SliderRow
|
|
label="Fade Duration"
|
|
description="How long the fade-in animation takes."
|
|
min={100}
|
|
max={2000}
|
|
step={50}
|
|
value={settings.fadeDurationMs}
|
|
onChange={(v) => set("fadeDurationMs", v)}
|
|
format={(v) => (v >= 1000 ? `${(v / 1000).toFixed(2)}s` : `${v}ms`)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Trash & deletion group. */
|
|
export function TrashGroup() {
|
|
const { settings, set } = useSettings();
|
|
return (
|
|
<div className="space-y-1">
|
|
<ToggleRow
|
|
label="Use Recycle Bin"
|
|
description="Send deletes to the recycle bin instead of removing immediately. Off makes every delete permanent."
|
|
value={settings.useRecycleBin}
|
|
onChange={(v) => set("useRecycleBin", v)}
|
|
/>
|
|
{settings.useRecycleBin && (
|
|
<SliderRow
|
|
label="Trash Retention"
|
|
description="Automatically purge trashed images older than this. 0 keeps them forever."
|
|
min={0}
|
|
max={365}
|
|
step={1}
|
|
value={settings.trashRetentionDays}
|
|
onChange={(v) => set("trashRetentionDays", v)}
|
|
format={(v) => v === 0 ? "Forever" : `${v} day${v === 1 ? "" : "s"}`}
|
|
/>
|
|
)}
|
|
<ToggleRow
|
|
label="Delete Files From Disk When Emptying Trash"
|
|
description="When permanently removing an image (or emptying the bin), also delete the file and thumbnail from disk. Off keeps files on disk."
|
|
value={settings.purgeFilesOnDelete}
|
|
onChange={(v) => set("purgeFilesOnDelete", v)}
|
|
/>
|
|
<SliderRow
|
|
label="Superseded Retention"
|
|
description={`When you replace a cover via the collision dialog, the old file is moved to library/.superseded/ as a recovery snapshot. Files older than this are auto-purged on each app start. 0 = keep forever.`}
|
|
min={0}
|
|
max={365}
|
|
step={1}
|
|
value={settings.supersededRetentionDays}
|
|
onChange={(v) => set("supersededRetentionDays", v)}
|
|
format={(v) => v === 0 ? "Forever" : `${v} day${v === 1 ? "" : "s"}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Maintenance buttons group. */
|
|
export function MaintenanceGroup() {
|
|
return (
|
|
<div className="divide-y divide-[var(--color-glass-border)]">
|
|
<PurgeOrphansButton />
|
|
<ReparseCodesButton />
|
|
<ReorganizeButton />
|
|
<RegenThumbnailsButton />
|
|
<UndersizedCoversButton />
|
|
<NearDupesButton />
|
|
<ClearCacheButton />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Backup group. */
|
|
export function BackupGroup() {
|
|
return (
|
|
<div className="space-y-1">
|
|
<BackupButtons />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Legacy combined view — preserved for any caller still rendering all
|
|
* groups inline. The new SettingsPanel layouts use the individual
|
|
* exports above. */
|
|
export function SettingsToggles() {
|
|
return (
|
|
<div className="space-y-5">
|
|
<Group title="Display"><DisplayGroup /></Group>
|
|
<Group title="Deletion & trash"><TrashGroup /></Group>
|
|
<Group title="Maintenance"><MaintenanceGroup /></Group>
|
|
<Group title="Backup"><BackupGroup /></Group>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Group({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-1.5">{title}</div>
|
|
<div className="border-t border-[var(--color-glass-border)] pt-1">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SliderRow({
|
|
label, description, min, max, step, value, onChange, format,
|
|
}: {
|
|
label: string;
|
|
description?: string;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
value: number;
|
|
onChange: (v: number) => void;
|
|
format?: (v: number) => string;
|
|
}) {
|
|
return (
|
|
<div className="py-2">
|
|
<div className="flex items-start justify-between gap-4 mb-2">
|
|
<div>
|
|
<div className="text-sm font-medium">{label}</div>
|
|
{description && (
|
|
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
|
|
)}
|
|
</div>
|
|
<span className="text-xs font-mono text-[var(--color-cyan)] tabular-nums whitespace-nowrap">
|
|
{format ? format(value) : value}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
className="w-full accent-[var(--color-cyan)]"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SegmentedRow<T extends string>({
|
|
label,
|
|
description,
|
|
value,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
description?: string;
|
|
value: T;
|
|
options: ReadonlyArray<{ value: T; label: string }>;
|
|
onChange: (v: T) => void;
|
|
}) {
|
|
return (
|
|
<div className="py-2">
|
|
<div className="flex items-start justify-between gap-4 mb-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{label}</div>
|
|
{description && (
|
|
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-shrink-0 rounded-lg border border-[var(--color-glass-border-strong)] overflow-hidden">
|
|
{options.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => onChange(opt.value)}
|
|
className={cn(
|
|
"min-w-[64px] px-3 py-1 text-xs font-mono transition-colors text-center",
|
|
value === opt.value
|
|
? "bg-[var(--color-cyan)]/20 text-[var(--color-cyan)]"
|
|
: "bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({
|
|
label,
|
|
description,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
description?: string;
|
|
value: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-4 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{label}</div>
|
|
{description && (
|
|
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={value}
|
|
onClick={() => onChange(!value)}
|
|
className={cn(
|
|
"relative w-11 h-6 rounded-full border transition-colors flex-shrink-0",
|
|
value
|
|
? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
|
|
: "bg-[var(--color-glass)] border-[var(--color-glass-border-strong)]"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"absolute top-0.5 w-4 h-4 rounded-full transition-all",
|
|
value
|
|
? "left-[22px] bg-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)]"
|
|
: "left-0.5 bg-[var(--color-fg-dim)]"
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|