Initial commit
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user