mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-29 14:00:52 +01:00
style: mobile improvements to quality profile tabs. Use sticky card for all pages, custom card view for scoring, better UX for quality ordering.
fix:custom format scoring bug that would stop updated scores from propogating
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
export let placeholder: string = 'Select...';
|
||||
export let minWidth: string = '8rem';
|
||||
export let position: 'left' | 'right' | 'middle' = 'left';
|
||||
export let mobilePosition: 'left' | 'right' | 'middle' | null = null;
|
||||
// Separate compact controls - compact is shorthand for both
|
||||
export let compact: boolean = false;
|
||||
export let compactButton: boolean | undefined = undefined;
|
||||
@@ -96,7 +97,7 @@
|
||||
on:click={() => !disabled && (open = !open)}
|
||||
/>
|
||||
{#if open}
|
||||
<Dropdown {position} {minWidth} compact={isCompactDropdown} {fixed} {triggerEl}>
|
||||
<Dropdown {position} {mobilePosition} {minWidth} compact={isCompactDropdown} {fixed} {triggerEl}>
|
||||
{#each options as option}
|
||||
<DropdownItem
|
||||
label={option.label}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
on:click={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { X, Check, ArrowUp, Info, Eye, EyeOff, Save, Loader2 } from 'lucide-svelte';
|
||||
import { tick, onMount, onDestroy } from 'svelte';
|
||||
import { X, Check, ArrowUp, Info, Save, Loader2, Layers, ChevronUp, ChevronDown } from 'lucide-svelte';
|
||||
import IconCheckbox from '$lib/client/ui/form/IconCheckbox.svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import { alertStore } from '$lib/client/alerts/store';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { current, isDirty, initEdit, update } from '$lib/client/stores/dirty';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let showInfoModal = false;
|
||||
let showLegacyQualities = true;
|
||||
|
||||
type OrderedItem = PageData['qualities']['orderedItems'][number];
|
||||
type QualityMember = PageData['qualities']['availableQualities'][number];
|
||||
|
||||
// Form data shape
|
||||
interface QualitiesFormData {
|
||||
orderedItems: OrderedItem[];
|
||||
availableQualities: QualityMember[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -32,27 +28,59 @@
|
||||
|
||||
function buildInitialData(qualities: typeof data.qualities): QualitiesFormData {
|
||||
return {
|
||||
orderedItems: structuredClone(qualities.orderedItems),
|
||||
availableQualities: structuredClone(qualities.availableQualities)
|
||||
orderedItems: structuredClone(qualities.orderedItems)
|
||||
};
|
||||
}
|
||||
|
||||
function buildMergedOrderedItems(qualities: typeof data.qualities): OrderedItem[] {
|
||||
const orderedItems = structuredClone(qualities.orderedItems);
|
||||
const additional = qualities.availableQualities.map((quality, index) => ({
|
||||
type: 'quality' as const,
|
||||
name: quality.name,
|
||||
position: orderedItems.length + index,
|
||||
enabled: false,
|
||||
upgradeUntil: false
|
||||
}));
|
||||
|
||||
const merged = [...orderedItems, ...additional];
|
||||
merged.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Initialize dirty tracking
|
||||
$: initEdit(initialData);
|
||||
|
||||
let lastAutoMergeKey = '';
|
||||
let lastQualitiesRef: typeof data.qualities | null = null;
|
||||
$: if (data.qualities) {
|
||||
if (data.qualities !== lastQualitiesRef) {
|
||||
lastAutoMergeKey = '';
|
||||
lastQualitiesRef = data.qualities;
|
||||
}
|
||||
const availableNames = data.qualities.availableQualities.map((q) => q.name).join('|');
|
||||
if (availableNames && availableNames !== lastAutoMergeKey) {
|
||||
update('orderedItems', buildMergedOrderedItems(data.qualities));
|
||||
}
|
||||
lastAutoMergeKey = availableNames;
|
||||
}
|
||||
|
||||
// Reactive getters for current values
|
||||
$: mainBucket = ($current.orderedItems ?? []) as OrderedItem[];
|
||||
$: legacyBucket = ($current.availableQualities ?? []) as QualityMember[];
|
||||
|
||||
// Save state
|
||||
let isSaving = false;
|
||||
let saveError: string | null = null;
|
||||
let selectedLayer: 'user' | 'base' = 'user';
|
||||
let showSaveTargetModal = false;
|
||||
let formElement: HTMLFormElement;
|
||||
|
||||
// Mobile detection
|
||||
let isMobile = false;
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
|
||||
// Drag state (local, not tracked for dirty)
|
||||
let draggedFromLegacy: QualityMember | null = null;
|
||||
let draggedQualityFromMain: { item: OrderedItem; index: number } | null = null;
|
||||
let lastTargetIndex: number | null = null;
|
||||
let hoverTargetIndex: number | null = null;
|
||||
@@ -62,16 +90,38 @@
|
||||
let editingGroupName: string = '';
|
||||
let groupingMode: boolean = false;
|
||||
|
||||
// Group creation modal state
|
||||
let showGroupModal = false;
|
||||
let groupNameInput = '';
|
||||
let selectedGroupNames = new Set<string>();
|
||||
|
||||
$: groupableItems = mainBucket.filter((item) => item.type === 'quality');
|
||||
$: selectedGroupCount = selectedGroupNames.size;
|
||||
$: canCreateGroup = selectedGroupCount >= 2;
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
mediaQuery = window.matchMedia('(max-width: 767px)');
|
||||
isMobile = mediaQuery.matches;
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
}
|
||||
});
|
||||
|
||||
function handleMediaChange(e: MediaQueryListEvent) {
|
||||
isMobile = e.matches;
|
||||
}
|
||||
|
||||
// Helper to update mainBucket in the store
|
||||
function updateMainBucket(newBucket: OrderedItem[]) {
|
||||
update('orderedItems', newBucket);
|
||||
}
|
||||
|
||||
// Helper to update legacyBucket in the store
|
||||
function updateLegacyBucket(newBucket: QualityMember[]) {
|
||||
update('availableQualities', newBucket);
|
||||
}
|
||||
|
||||
function handleQualityDragStart(item: OrderedItem, index: number) {
|
||||
draggedQualityFromMain = { item, index };
|
||||
}
|
||||
@@ -196,6 +246,20 @@
|
||||
willAddToGroup = false;
|
||||
}
|
||||
|
||||
function moveItem(index: number, direction: 'up' | 'down') {
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= mainBucket.length) return;
|
||||
|
||||
const newBucket = [...mainBucket];
|
||||
const [movedItem] = newBucket.splice(index, 1);
|
||||
newBucket.splice(targetIndex, 0, movedItem);
|
||||
|
||||
newBucket.forEach((item, idx) => {
|
||||
item.position = idx;
|
||||
});
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
|
||||
function startEditingGroupName(item: OrderedItem, index: number) {
|
||||
if (item.type === 'group') {
|
||||
editingGroupIndex = index;
|
||||
@@ -252,42 +316,6 @@
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
|
||||
function handleDragStart(item: QualityMember) {
|
||||
draggedFromLegacy = item;
|
||||
}
|
||||
|
||||
function handleDragOverMain(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleDropOnMain(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (!draggedFromLegacy) return;
|
||||
|
||||
const newLegacyBucket = legacyBucket.filter((q) => q.name !== draggedFromLegacy!.name);
|
||||
updateLegacyBucket(newLegacyBucket);
|
||||
|
||||
const newItem: OrderedItem = {
|
||||
type: 'quality',
|
||||
name: draggedFromLegacy.name,
|
||||
position: mainBucket.length,
|
||||
enabled: false,
|
||||
upgradeUntil: false
|
||||
};
|
||||
|
||||
const newBucket = [...mainBucket, newItem];
|
||||
newBucket.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
updateMainBucket(newBucket);
|
||||
|
||||
draggedFromLegacy = null;
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggedFromLegacy = null;
|
||||
}
|
||||
|
||||
function collapseGroup(group: OrderedItem) {
|
||||
if (group.type !== 'group' || !group.members) return;
|
||||
|
||||
@@ -314,6 +342,66 @@
|
||||
updateMainBucket(newBucket);
|
||||
}
|
||||
|
||||
function openGroupModal() {
|
||||
showGroupModal = true;
|
||||
groupNameInput = '';
|
||||
selectedGroupNames = new Set();
|
||||
}
|
||||
|
||||
function closeGroupModal() {
|
||||
showGroupModal = false;
|
||||
groupNameInput = '';
|
||||
selectedGroupNames = new Set();
|
||||
}
|
||||
|
||||
function toggleGroupSelection(name: string) {
|
||||
if (selectedGroupNames.has(name)) {
|
||||
selectedGroupNames.delete(name);
|
||||
} else {
|
||||
selectedGroupNames.add(name);
|
||||
}
|
||||
selectedGroupNames = selectedGroupNames;
|
||||
}
|
||||
|
||||
function handleCreateGroup() {
|
||||
if (!canCreateGroup) return;
|
||||
|
||||
const selectedItems = mainBucket.filter(
|
||||
(item) => item.type === 'quality' && selectedGroupNames.has(item.name)
|
||||
) as OrderedItem[];
|
||||
|
||||
if (selectedItems.length < 2) return;
|
||||
|
||||
const insertIndex = mainBucket.findIndex(
|
||||
(item) => item.type === 'quality' && selectedGroupNames.has(item.name)
|
||||
);
|
||||
if (insertIndex === -1) return;
|
||||
|
||||
const groupName =
|
||||
groupNameInput.trim() || selectedItems.map((item) => item.name).join(' + ');
|
||||
|
||||
const newGroup: OrderedItem = {
|
||||
type: 'group',
|
||||
name: groupName,
|
||||
position: insertIndex,
|
||||
enabled: selectedItems.every((item) => item.enabled),
|
||||
upgradeUntil: selectedItems.some((item) => item.upgradeUntil),
|
||||
members: selectedItems.map((item) => ({ name: item.name }))
|
||||
};
|
||||
|
||||
const newBucket = mainBucket.filter(
|
||||
(item) => !(item.type === 'quality' && selectedGroupNames.has(item.name))
|
||||
);
|
||||
newBucket.splice(insertIndex, 0, newGroup);
|
||||
|
||||
newBucket.forEach((item, index) => {
|
||||
item.position = index;
|
||||
});
|
||||
|
||||
updateMainBucket(newBucket);
|
||||
closeGroupModal();
|
||||
}
|
||||
|
||||
async function handleSaveClick() {
|
||||
if (data.canWriteToBase) {
|
||||
showSaveTargetModal = true;
|
||||
@@ -330,267 +418,232 @@
|
||||
await tick();
|
||||
formElement?.requestSubmit();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Qualities - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Save Bar -->
|
||||
{#if $isDirty}
|
||||
<div
|
||||
class="sticky top-0 z-40 -mx-8 mb-6 flex items-center justify-between border-b border-neutral-200 bg-white/95 px-8 py-3 backdrop-blur-sm dark:border-neutral-700 dark:bg-neutral-900/95"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-amber-600 dark:text-amber-400">Unsaved changes</span>
|
||||
{#if saveError}
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{saveError}</span>
|
||||
{/if}
|
||||
<StickyCard position="top">
|
||||
<svelte:fragment slot="left">
|
||||
<h1 class="text-neutral-900 dark:text-neutral-50">Qualities</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
Configure ordering, grouping, and upgrade behavior.
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="right">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button text="Info" icon={Info} on:click={() => (showInfoModal = true)} />
|
||||
<Button text="Create Group" icon={Layers} on:click={openGroupModal} />
|
||||
<Button
|
||||
disabled={isSaving || !$isDirty}
|
||||
icon={isSaving ? Loader2 : Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
text={isSaving ? 'Saving...' : 'Save'}
|
||||
on:click={handleSaveClick}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</StickyCard>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
on:click={handleSaveClick}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
{#if isSaving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden form for submission -->
|
||||
<form
|
||||
bind:this={formElement}
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
alertStore.add('success', 'Qualities saved!');
|
||||
initEdit(initialData);
|
||||
await formUpdate();
|
||||
} else if (result.type === 'failure') {
|
||||
saveError = (result.data as { error?: string })?.error || 'Failed to save';
|
||||
alertStore.add('error', saveError);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="orderedItems" value={JSON.stringify(mainBucket)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<ActionsBar>
|
||||
<ActionButton icon={Info} on:click={() => (showInfoModal = true)} />
|
||||
<ActionButton
|
||||
icon={showLegacyQualities ? EyeOff : Eye}
|
||||
on:click={() => (showLegacyQualities = !showLegacyQualities)}
|
||||
/>
|
||||
</ActionsBar>
|
||||
<!-- Hidden form for submission -->
|
||||
<form
|
||||
bind:this={formElement}
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
alertStore.add('success', 'Qualities saved!');
|
||||
await formUpdate();
|
||||
} else if (result.type === 'failure') {
|
||||
const message = (result.data as { error?: string })?.error || 'Failed to save';
|
||||
alertStore.add('error', message);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="orderedItems" value={JSON.stringify(mainBucket)} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
</form>
|
||||
|
||||
<div class="mt-6 space-y-6 md:px-4">
|
||||
<div
|
||||
class="grid gap-6"
|
||||
class:grid-cols-2={showLegacyQualities}
|
||||
class:grid-cols-1={!showLegacyQualities}
|
||||
class="min-h-[36rem] rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-900"
|
||||
role="region"
|
||||
aria-label="Main quality configuration"
|
||||
>
|
||||
<!-- Main Bucket -->
|
||||
<div>
|
||||
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Qualities</h2>
|
||||
{#if mainBucket.length === 0}
|
||||
<div
|
||||
class="min-h-[36rem] rounded-lg border-2 border-dashed border-neutral-300 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-900"
|
||||
on:dragover={handleDragOverMain}
|
||||
on:drop={handleDropOnMain}
|
||||
role="region"
|
||||
aria-label="Main quality configuration"
|
||||
class="flex h-full items-center justify-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{#if mainBucket.length === 0}
|
||||
No qualities found
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.name}-${index}` : `group-${item.name}-${index}`)}
|
||||
<div
|
||||
class="flex h-full items-center justify-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
draggable={!isMobile}
|
||||
on:dragstart={() => handleQualityDragStart(item, index)}
|
||||
on:dragover={(e) => handleQualityDragOver(e, item, index)}
|
||||
on:dragleave={handleQualityDragLeave}
|
||||
on:drop={(e) => handleQualityDrop(e, item, index)}
|
||||
on:dragend={resetDragState}
|
||||
on:click={() => toggleEnabled(index)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleEnabled(index);
|
||||
}
|
||||
}}
|
||||
class="relative rounded-lg border border-neutral-200 bg-neutral-50 p-3 hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700 {isMobile
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-move'} {draggedQualityFromMain?.index ===
|
||||
index
|
||||
? 'scale-95 opacity-50'
|
||||
: ''}"
|
||||
style="transition: opacity 100ms, transform 100ms;"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Drop qualities here
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.name}-${index}` : `group-${item.name}-${index}`)}
|
||||
{#if hoverTargetIndex === index && willCreateGroup}
|
||||
<div
|
||||
draggable={true}
|
||||
on:dragstart={() => handleQualityDragStart(item, index)}
|
||||
on:dragover={(e) => handleQualityDragOver(e, item, index)}
|
||||
on:dragleave={handleQualityDragLeave}
|
||||
on:drop={(e) => handleQualityDrop(e, item, index)}
|
||||
on:dragend={resetDragState}
|
||||
on:click={() => toggleEnabled(index)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleEnabled(index);
|
||||
}
|
||||
}}
|
||||
class="relative cursor-move rounded-lg border border-neutral-200 bg-neutral-50 p-3 hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700 {draggedQualityFromMain?.index ===
|
||||
index
|
||||
? 'scale-95 opacity-50'
|
||||
: ''}"
|
||||
style="transition: opacity 100ms, transform 100ms;"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if hoverTargetIndex === index && willCreateGroup}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-lg border-2 border-dashed border-green-500 bg-green-50/30 dark:border-green-400 dark:bg-green-950/30"
|
||||
></div>
|
||||
{/if}
|
||||
{#if hoverTargetIndex === index && willAddToGroup}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-lg border-2 border-dashed border-accent-500 bg-accent-50/30 dark:border-accent-400 dark:bg-accent-950/30"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
{#if item.type === 'group' && editingGroupIndex === index}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingGroupName}
|
||||
on:blur={saveGroupName}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveGroupName();
|
||||
if (e.key === 'Escape') cancelEditingGroupName();
|
||||
}}
|
||||
class="max-w-xs rounded border border-accent-500 bg-white px-2 py-1 text-sm font-medium text-neutral-900 focus:ring-2 focus:ring-accent-500 focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
|
||||
/>
|
||||
{:else}
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{#if item.type === 'group'}
|
||||
<span
|
||||
class="cursor-pointer hover:text-accent-600 dark:hover:text-accent-400"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditingGroupName(item, index);
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startEditingGroupName(item, index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-neutral-500 dark:text-neutral-400"
|
||||
>(Group)</span
|
||||
>
|
||||
{:else}
|
||||
{item.name}
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.type === 'group' && item.members}
|
||||
<div class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{item.members.map((m) => m.name).join(', ')}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if hoverTargetIndex === index && willCreateGroup}
|
||||
<div class="text-xs font-medium text-green-600 dark:text-green-400">
|
||||
Create Group
|
||||
</div>
|
||||
{:else if hoverTargetIndex === index && willAddToGroup}
|
||||
<div class="text-xs font-medium text-accent-600 dark:text-accent-400">
|
||||
Add to Group
|
||||
</div>
|
||||
{/if}
|
||||
class="pointer-events-none absolute inset-0 rounded-lg border-2 border-dashed border-green-500 bg-green-50/30 dark:border-green-400 dark:bg-green-950/30"
|
||||
></div>
|
||||
{/if}
|
||||
{#if hoverTargetIndex === index && willAddToGroup}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-lg border-2 border-dashed border-accent-500 bg-accent-50/30 dark:border-accent-400 dark:bg-accent-950/30"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
{#if item.type === 'group' && editingGroupIndex === index}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingGroupName}
|
||||
on:blur={saveGroupName}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveGroupName();
|
||||
if (e.key === 'Escape') cancelEditingGroupName();
|
||||
}}
|
||||
class="max-w-xs rounded border border-accent-500 bg-white px-2 py-1 text-sm font-medium text-neutral-900 focus:ring-2 focus:ring-accent-500 focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
|
||||
/>
|
||||
{:else}
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{#if item.type === 'group'}
|
||||
<button
|
||||
<span
|
||||
class="cursor-pointer hover:text-accent-600 dark:hover:text-accent-400"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
collapseGroup(item);
|
||||
startEditingGroupName(item, index);
|
||||
}}
|
||||
class="rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
|
||||
title="Collapse group into individual qualities"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startEditingGroupName(item, index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
{item.name}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-neutral-500 dark:text-neutral-400">(Group)</span>
|
||||
{:else}
|
||||
{item.name}
|
||||
{/if}
|
||||
<IconCheckbox
|
||||
checked={item.upgradeUntil}
|
||||
icon={ArrowUp}
|
||||
color="#07CA07"
|
||||
shape="circle"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleUpgradeUntil(index);
|
||||
}}
|
||||
/>
|
||||
<IconCheckbox
|
||||
checked={item.enabled}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleEnabled(index);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if item.type === 'group' && item.members}
|
||||
<div class="mt-1 hidden text-xs text-neutral-600 dark:text-neutral-400 md:block">
|
||||
{item.members.map((m) => m.name).join(', ')}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
on:click|stopPropagation={() => moveItem(index, 'up')}
|
||||
class="flex h-7 w-7 items-center justify-center rounded border border-neutral-200 bg-white text-neutral-600 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === mainBucket.length - 1}
|
||||
on:click|stopPropagation={() => moveItem(index, 'down')}
|
||||
class="flex h-7 w-7 items-center justify-center rounded border border-neutral-200 bg-white text-neutral-600 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{#if hoverTargetIndex === index && willCreateGroup}
|
||||
<div class="text-xs font-medium text-green-600 dark:text-green-400">
|
||||
Create Group
|
||||
</div>
|
||||
{:else if hoverTargetIndex === index && willAddToGroup}
|
||||
<div class="text-xs font-medium text-accent-600 dark:text-accent-400">
|
||||
Add to Group
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.type === 'group'}
|
||||
<button
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
collapseGroup(item);
|
||||
}}
|
||||
class="rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
|
||||
title="Collapse group into individual qualities"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
<IconCheckbox
|
||||
checked={item.upgradeUntil}
|
||||
icon={ArrowUp}
|
||||
color="#07CA07"
|
||||
shape="circle"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleUpgradeUntil(index);
|
||||
}}
|
||||
/>
|
||||
<IconCheckbox
|
||||
checked={item.enabled}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleEnabled(index);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if item.type === 'group' && item.members}
|
||||
<div
|
||||
class="mt-3 border-t border-neutral-200 pt-3 text-xs text-neutral-600 dark:border-neutral-700 dark:text-neutral-400 md:hidden"
|
||||
>
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-neutral-500">
|
||||
Group Members
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{item.members.map((m) => m.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="h-[30px]"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy Bucket -->
|
||||
{#if showLegacyQualities}
|
||||
<div>
|
||||
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Unmigrated Qualities
|
||||
</h2>
|
||||
<div
|
||||
class="min-h-96 rounded-lg border-2 border-dashed border-neutral-300 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-900"
|
||||
>
|
||||
{#if legacyBucket.length === 0}
|
||||
<div
|
||||
class="flex h-full items-center justify-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
All qualities added!
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each legacyBucket as quality}
|
||||
<div
|
||||
draggable={true}
|
||||
on:dragstart={() => handleDragStart(quality)}
|
||||
on:dragend={handleDragEnd}
|
||||
class="cursor-move rounded-lg border border-neutral-200 bg-neutral-50 p-3 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{quality.name}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="h-[30px]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -603,7 +656,8 @@
|
||||
<div class="mt-1">
|
||||
Define the order, grouping, and configuration of qualities. In previous versions, only
|
||||
enabled qualities were tracked. The new system stores all qualities (enabled and disabled)
|
||||
to maintain proper ordering across your entire quality profile.
|
||||
to maintain proper ordering across your entire quality profile. Missing qualities are
|
||||
automatically appended as disabled.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -619,9 +673,10 @@
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Creating Groups</div>
|
||||
<div class="mt-1">
|
||||
Hold Ctrl (or Cmd on Mac) while dragging a quality onto another quality to create a group.
|
||||
Groups allow multiple qualities to be treated as equal priority. You can also drag a quality
|
||||
onto an existing group to add it to that group.
|
||||
On desktop, hold Ctrl (or Cmd on Mac) while dragging a quality onto another quality to create
|
||||
a group. Groups allow multiple qualities to be treated as equal priority. You can also drag
|
||||
a quality onto an existing group to add it to that group. On mobile, use the Create Group
|
||||
button in the header.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -659,17 +714,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Unmigrated Qualities</div>
|
||||
<div class="mt-1">
|
||||
When migrating from older profile versions, qualities that weren't previously enabled appear
|
||||
in the "Unmigrated Qualities" section. Drag these into your main configuration to include
|
||||
them in your quality profile ordering. These start as disabled by default.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<Modal
|
||||
open={showGroupModal}
|
||||
header="Create Group"
|
||||
confirmText="Create Group"
|
||||
cancelText="Cancel"
|
||||
confirmDisabled={!canCreateGroup}
|
||||
on:confirm={handleCreateGroup}
|
||||
on:cancel={closeGroupModal}
|
||||
>
|
||||
<div slot="body" class="space-y-4">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Select at least two qualities to combine into a group. Groups share the same priority.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Group Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={groupNameInput}
|
||||
placeholder="e.g. Web + Bluray"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:ring-2 focus:ring-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-72 overflow-auto rounded-lg border border-neutral-200 p-2 dark:border-neutral-700">
|
||||
{#if groupableItems.length < 2}
|
||||
<div class="px-3 py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
At least two qualities are required to create a group.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each groupableItems as item}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleGroupSelection(item.name)}
|
||||
class="flex w-full items-center justify-between gap-3 rounded-md border border-neutral-200 bg-white px-3 py-2 text-left transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<IconCheckbox
|
||||
checked={selectedGroupNames.has(item.name)}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{item.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Selected: {selectedGroupCount}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Save Target Modal -->
|
||||
{#if data.canWriteToBase}
|
||||
<SaveTargetModal
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
} from 'lucide-svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
@@ -415,7 +417,7 @@
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
$: sortedCustomFormats = sortFormats(filteredCustomFormats, customFormatScores, sortState);
|
||||
$: sortedCustomFormats = sortFormats(filteredCustomFormats, initialData.customFormatScores, sortState);
|
||||
$: groupedFormats = groupFormats(sortedCustomFormats, selectedGroups);
|
||||
|
||||
// Apply default sort
|
||||
@@ -429,20 +431,22 @@
|
||||
}
|
||||
|
||||
// Build custom format scores array for form submission
|
||||
function buildCustomFormatScoresArray(): Array<{
|
||||
function buildCustomFormatScoresArray(
|
||||
currentScores: Record<string, Record<string, number | null>>,
|
||||
initialScores: Record<string, Record<string, number | null>>
|
||||
): Array<{
|
||||
customFormatName: string;
|
||||
arrType: string;
|
||||
score: number | null;
|
||||
}> {
|
||||
const result: Array<{ customFormatName: string; arrType: string; score: number | null }> = [];
|
||||
const initial = initialData.customFormatScores;
|
||||
|
||||
for (const [cfName, arrTypeScores] of Object.entries(customFormatScores)) {
|
||||
const initialScores = initial[cfName] || {};
|
||||
for (const [cfName, arrTypeScores] of Object.entries(currentScores)) {
|
||||
const initialScoresForFormat = initialScores[cfName] || {};
|
||||
|
||||
for (const [arrType, score] of Object.entries(arrTypeScores)) {
|
||||
// Only include if different from initial
|
||||
if (score !== initialScores[arrType]) {
|
||||
if (score !== initialScoresForFormat[arrType]) {
|
||||
result.push({ customFormatName: cfName, arrType, score });
|
||||
}
|
||||
}
|
||||
@@ -451,6 +455,10 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
$: customFormatScoresPayload = JSON.stringify(
|
||||
buildCustomFormatScoresArray(customFormatScores, initialData.customFormatScores)
|
||||
);
|
||||
|
||||
// Handlers for score changes from ScoringTable
|
||||
function handleScoreChange(
|
||||
event: CustomEvent<{ formatName: string; arrType: string; score: number | null }>
|
||||
@@ -573,70 +581,57 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if scoring}
|
||||
<!-- Save Bar -->
|
||||
{#if $isDirty}
|
||||
<div
|
||||
class="sticky top-0 z-40 -mx-8 mb-6 flex items-center justify-between border-b border-neutral-200 bg-white/95 px-8 py-3 backdrop-blur-sm dark:border-neutral-700 dark:bg-neutral-900/95"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-amber-600 dark:text-amber-400">Unsaved changes</span>
|
||||
{#if saveError}
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{saveError}</span>
|
||||
{/if}
|
||||
<StickyCard position="top">
|
||||
<svelte:fragment slot="left">
|
||||
<h1 class="text-neutral-900 dark:text-neutral-50">Scoring</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">Configure custom format scores</p>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="right">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button text="Scoring" icon={Info} on:click={() => (showInfoModal = true)} />
|
||||
<Button text="Options" icon={Info} on:click={() => (showOptionsInfoModal = true)} />
|
||||
<Button
|
||||
disabled={isSaving || !$isDirty}
|
||||
icon={isSaving ? Loader2 : Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
text={isSaving ? 'Saving...' : 'Save'}
|
||||
on:click={handleSaveClick}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</StickyCard>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
on:click={handleSaveClick}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
{#if isSaving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Hidden form for submission -->
|
||||
<form
|
||||
bind:this={formElement}
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
alertStore.add('success', 'Scoring saved!');
|
||||
// Mark as clean so navigation guard doesn't trigger
|
||||
initEdit(initialData);
|
||||
await formUpdate();
|
||||
} else if (result.type === 'failure') {
|
||||
saveError = (result.data as { error?: string })?.error || 'Failed to save';
|
||||
alertStore.add('error', saveError);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="minimumScore" value={minimumScore} />
|
||||
<input type="hidden" name="upgradeUntilScore" value={upgradeUntilScore} />
|
||||
<input type="hidden" name="upgradeScoreIncrement" value={upgradeScoreIncrement} />
|
||||
<input type="hidden" name="customFormatScores" value={customFormatScoresPayload} />
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
</form>
|
||||
|
||||
<!-- Hidden form for submission -->
|
||||
<form
|
||||
bind:this={formElement}
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="hidden"
|
||||
use:enhance={() => {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
isSaving = false;
|
||||
if (result.type === 'success') {
|
||||
alertStore.add('success', 'Scoring saved!');
|
||||
// Mark as clean so navigation guard doesn't trigger
|
||||
initEdit(initialData);
|
||||
await formUpdate();
|
||||
} else if (result.type === 'failure') {
|
||||
saveError = (result.data as { error?: string })?.error || 'Failed to save';
|
||||
alertStore.add('error', saveError);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="minimumScore" value={minimumScore} />
|
||||
<input type="hidden" name="upgradeUntilScore" value={upgradeUntilScore} />
|
||||
<input type="hidden" name="upgradeScoreIncrement" value={upgradeScoreIncrement} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="customFormatScores"
|
||||
value={JSON.stringify(buildCustomFormatScoresArray())}
|
||||
/>
|
||||
<input type="hidden" name="layer" value={selectedLayer} />
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="mt-6 space-y-6 md:px-4">
|
||||
<!-- Profile-level Score Settings -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
@@ -697,31 +692,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Custom Format Scoring
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Configure custom format scores for each Arr type
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showInfoModal = true)}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<Info size={14} />
|
||||
Info
|
||||
</button>
|
||||
</div>
|
||||
<!-- Custom Format Scoring -->
|
||||
|
||||
<ActionsBar className="w-full">
|
||||
<SearchAction {searchStore} placeholder="Search custom formats..." />
|
||||
<ActionsBar className="md:w-full">
|
||||
<SearchAction {searchStore} placeholder="Search custom formats..." responsive />
|
||||
<ActionButton icon={ArrowUpDown} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="10rem">
|
||||
<Dropdown position={dropdownPosition} mobilePosition="middle" minWidth="10rem">
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -783,7 +760,7 @@
|
||||
</ActionButton>
|
||||
<ActionButton icon={Layers} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="14rem">
|
||||
<Dropdown position={dropdownPosition} mobilePosition="middle" minWidth="14rem">
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -833,7 +810,7 @@
|
||||
</ActionButton>
|
||||
<ActionButton icon={LayoutGrid} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="10rem">
|
||||
<Dropdown position={dropdownPosition} mobilePosition="middle" minWidth="10rem">
|
||||
<div class="py-1">
|
||||
{#each [1, 2, 3] as columns}
|
||||
<button
|
||||
@@ -861,7 +838,7 @@
|
||||
</ActionButton>
|
||||
<ActionButton icon={Settings} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} minWidth="14rem">
|
||||
<Dropdown position={dropdownPosition} mobilePosition="middle" minWidth="14rem">
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -969,7 +946,6 @@
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={Info} on:click={() => (showOptionsInfoModal = true)} />
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Custom Format Scores Tables -->
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import NumberInput from '$ui/form/NumberInput.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
export let formats: any[];
|
||||
export let arrTypes: string[];
|
||||
export let customFormatScores: Record<string, Record<string, number | null>>;
|
||||
@@ -11,28 +6,8 @@
|
||||
export let getArrTypeColor: (arrType: string) => string;
|
||||
export let title: string | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
scoreChange: { formatName: string; arrType: string; score: number | null };
|
||||
enabledChange: { formatName: string; arrType: string; enabled: boolean };
|
||||
}>();
|
||||
|
||||
function handleScoreChange(formatName: string, arrType: string, score: number | null) {
|
||||
dispatch('scoreChange', { formatName, arrType, score });
|
||||
}
|
||||
|
||||
function handleToggleEnabled(formatName: string, arrType: string) {
|
||||
const isEnabled = customFormatEnabled[formatName]?.[arrType] ?? false;
|
||||
if (isEnabled) {
|
||||
// Disabling - set score to null
|
||||
dispatch('scoreChange', { formatName, arrType, score: null });
|
||||
} else {
|
||||
// Enabling - set score to 0 if it was null
|
||||
if (customFormatScores[formatName]?.[arrType] === null) {
|
||||
dispatch('scoreChange', { formatName, arrType, score: 0 });
|
||||
}
|
||||
}
|
||||
dispatch('enabledChange', { formatName, arrType, enabled: !isEnabled });
|
||||
}
|
||||
import ScoringTableDesktop from './ScoringTableDesktop.svelte';
|
||||
import ScoringTableMobile from './ScoringTableMobile.svelte';
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
@@ -41,84 +16,28 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<!-- Header -->
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
class="sticky left-0 z-10 bg-neutral-50 px-6 py-3 text-left text-xs font-medium tracking-wider text-neutral-700 uppercase dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
Custom Format
|
||||
</th>
|
||||
{#each arrTypes as arrType}
|
||||
<th
|
||||
class="w-64 px-6 py-3 text-center text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300"
|
||||
>
|
||||
{arrType}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if formats.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={arrTypes.length + 1}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
No custom formats found
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each formats as format}
|
||||
{@const rowDisabled = arrTypes.every(
|
||||
(arrType) => !customFormatEnabled[format.name]?.[arrType]
|
||||
)}
|
||||
<tr
|
||||
class="transition-colors {rowDisabled
|
||||
? 'bg-neutral-100 opacity-60 dark:bg-neutral-800'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-900'}"
|
||||
>
|
||||
<td
|
||||
class="sticky left-0 z-10 px-6 py-4 text-sm font-medium {rowDisabled
|
||||
? 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-500'
|
||||
: 'bg-white text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100'}"
|
||||
>
|
||||
{format.name}
|
||||
</td>
|
||||
{#each arrTypes as arrType}
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<IconCheckbox
|
||||
checked={customFormatEnabled[format.name]?.[arrType] ?? false}
|
||||
icon={Check}
|
||||
color={getArrTypeColor(arrType)}
|
||||
shape="circle"
|
||||
on:click={() => handleToggleEnabled(format.name, arrType)}
|
||||
/>
|
||||
{#if customFormatScores[format.name]}
|
||||
<div class="w-48">
|
||||
<NumberInput
|
||||
name="score-{format.name}-{arrType}"
|
||||
value={customFormatScores[format.name][arrType] ?? 0}
|
||||
onchange={(newValue) => handleScoreChange(format.name, arrType, newValue)}
|
||||
step={1}
|
||||
disabled={!customFormatEnabled[format.name]?.[arrType]}
|
||||
font="mono"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Desktop -->
|
||||
<div class="hidden md:block">
|
||||
<ScoringTableDesktop
|
||||
{formats}
|
||||
{arrTypes}
|
||||
{customFormatScores}
|
||||
{customFormatEnabled}
|
||||
{getArrTypeColor}
|
||||
on:scoreChange
|
||||
on:enabledChange
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="md:hidden">
|
||||
<ScoringTableMobile
|
||||
{formats}
|
||||
{arrTypes}
|
||||
{customFormatScores}
|
||||
{customFormatEnabled}
|
||||
{getArrTypeColor}
|
||||
on:scoreChange
|
||||
on:enabledChange
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import NumberInput from '$ui/form/NumberInput.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
export let formats: any[];
|
||||
export let arrTypes: string[];
|
||||
export let customFormatScores: Record<string, Record<string, number | null>>;
|
||||
export let customFormatEnabled: Record<string, Record<string, boolean>>;
|
||||
export let getArrTypeColor: (arrType: string) => string;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
scoreChange: { formatName: string; arrType: string; score: number | null };
|
||||
enabledChange: { formatName: string; arrType: string; enabled: boolean };
|
||||
}>();
|
||||
|
||||
function handleScoreChange(formatName: string, arrType: string, score: number | null) {
|
||||
dispatch('scoreChange', { formatName, arrType, score });
|
||||
}
|
||||
|
||||
function handleToggleEnabled(formatName: string, arrType: string) {
|
||||
const isEnabled = customFormatEnabled[formatName]?.[arrType] ?? false;
|
||||
if (isEnabled) {
|
||||
dispatch('scoreChange', { formatName, arrType, score: null });
|
||||
} else {
|
||||
if (customFormatScores[formatName]?.[arrType] === null) {
|
||||
dispatch('scoreChange', { formatName, arrType, score: 0 });
|
||||
}
|
||||
}
|
||||
dispatch('enabledChange', { formatName, arrType, enabled: !isEnabled });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
class="sticky left-0 z-[1] bg-neutral-50 px-6 py-3 text-left text-xs font-medium tracking-wider text-neutral-700 uppercase dark:bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
Custom Format
|
||||
</th>
|
||||
{#each arrTypes as arrType}
|
||||
<th
|
||||
class="w-64 px-6 py-3 text-center text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300"
|
||||
>
|
||||
{arrType}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if formats.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={arrTypes.length + 1}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
No custom formats found
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each formats as format}
|
||||
{@const rowDisabled = arrTypes.every(
|
||||
(arrType) => !customFormatEnabled[format.name]?.[arrType]
|
||||
)}
|
||||
<tr
|
||||
class="transition-colors {rowDisabled
|
||||
? 'bg-neutral-100 opacity-60 dark:bg-neutral-800'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-900'}"
|
||||
>
|
||||
<td
|
||||
class="sticky left-0 z-[1] px-6 py-4 text-sm font-medium {rowDisabled
|
||||
? 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-500'
|
||||
: 'bg-white text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100'}"
|
||||
>
|
||||
{format.name}
|
||||
</td>
|
||||
{#each arrTypes as arrType}
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<IconCheckbox
|
||||
checked={customFormatEnabled[format.name]?.[arrType] ?? false}
|
||||
icon={Check}
|
||||
color={getArrTypeColor(arrType)}
|
||||
shape="circle"
|
||||
on:click={() => handleToggleEnabled(format.name, arrType)}
|
||||
/>
|
||||
{#if customFormatScores[format.name]}
|
||||
<div class="w-48">
|
||||
<NumberInput
|
||||
name="score-{format.name}-{arrType}"
|
||||
value={customFormatScores[format.name][arrType] ?? 0}
|
||||
onchange={(newValue) => handleScoreChange(format.name, arrType, newValue)}
|
||||
step={1}
|
||||
disabled={!customFormatEnabled[format.name]?.[arrType]}
|
||||
font="mono"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import NumberInput from '$ui/form/NumberInput.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
export let formats: any[];
|
||||
export let arrTypes: string[];
|
||||
export let customFormatScores: Record<string, Record<string, number | null>>;
|
||||
export let customFormatEnabled: Record<string, Record<string, boolean>>;
|
||||
export let getArrTypeColor: (arrType: string) => string;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
scoreChange: { formatName: string; arrType: string; score: number | null };
|
||||
enabledChange: { formatName: string; arrType: string; enabled: boolean };
|
||||
}>();
|
||||
|
||||
function handleScoreChange(formatName: string, arrType: string, score: number | null) {
|
||||
dispatch('scoreChange', { formatName, arrType, score });
|
||||
}
|
||||
|
||||
function handleToggleEnabled(formatName: string, arrType: string) {
|
||||
const isEnabled = customFormatEnabled[formatName]?.[arrType] ?? false;
|
||||
if (isEnabled) {
|
||||
dispatch('scoreChange', { formatName, arrType, score: null });
|
||||
} else {
|
||||
if (customFormatScores[formatName]?.[arrType] === null) {
|
||||
dispatch('scoreChange', { formatName, arrType, score: 0 });
|
||||
}
|
||||
}
|
||||
dispatch('enabledChange', { formatName, arrType, enabled: !isEnabled });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#if formats.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white px-4 py-8 text-center text-sm text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400"
|
||||
>
|
||||
No custom formats found
|
||||
</div>
|
||||
{:else}
|
||||
{#each formats as format}
|
||||
{@const rowDisabled = arrTypes.every(
|
||||
(arrType) => !customFormatEnabled[format.name]?.[arrType]
|
||||
)}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900 {rowDisabled
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Format name -->
|
||||
<div
|
||||
class="border-b border-neutral-200 px-4 py-2.5 text-sm font-medium dark:border-neutral-800 {rowDisabled
|
||||
? 'text-neutral-500 dark:text-neutral-500'
|
||||
: 'text-neutral-900 dark:text-neutral-100'}"
|
||||
>
|
||||
{format.name}
|
||||
</div>
|
||||
|
||||
<!-- Arr type scores -->
|
||||
<div class="divide-y divide-neutral-100 px-4 dark:divide-neutral-800">
|
||||
{#each arrTypes as arrType}
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
checked={customFormatEnabled[format.name]?.[arrType] ?? false}
|
||||
icon={Check}
|
||||
color={getArrTypeColor(arrType)}
|
||||
shape="circle"
|
||||
on:click={() => handleToggleEnabled(format.name, arrType)}
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium capitalize text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
{arrType}
|
||||
</span>
|
||||
</div>
|
||||
{#if customFormatScores[format.name]}
|
||||
<div class="w-28">
|
||||
<NumberInput
|
||||
name="score-{format.name}-{arrType}"
|
||||
value={customFormatScores[format.name][arrType] ?? 0}
|
||||
onchange={(newValue) =>
|
||||
handleScoreChange(format.name, arrType, newValue)}
|
||||
step={1}
|
||||
disabled={!customFormatEnabled[format.name]?.[arrType]}
|
||||
responsive={true}
|
||||
font="mono"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { current, isDirty, initEdit, initCreate, update } from '$lib/client/stores/dirty';
|
||||
|
||||
@@ -174,20 +175,42 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header (only shown in create mode, edit mode uses layout tabs) -->
|
||||
{#if mode === 'create'}
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{description_}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Header with actions -->
|
||||
<StickyCard position="top">
|
||||
<svelte:fragment slot="left">
|
||||
<h1 class="text-neutral-900 dark:text-neutral-50">{title}</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">{description_}</p>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="right">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if mode === 'edit'}
|
||||
<Button
|
||||
disabled={deleting}
|
||||
icon={deleting ? Loader2 : Trash2}
|
||||
iconColor="text-red-600 dark:text-red-400"
|
||||
text={deleting ? 'Deleting...' : 'Delete'}
|
||||
on:click={handleDeleteClick}
|
||||
/>
|
||||
{/if}
|
||||
{#if onCancel}
|
||||
<Button text="Cancel" on:click={onCancel} />
|
||||
{/if}
|
||||
<Button
|
||||
disabled={saving || !isValid || !$isDirty}
|
||||
icon={saving ? Loader2 : Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
text={saving ? (mode === 'create' ? 'Creating...' : 'Saving...') : submitButtonText}
|
||||
on:click={handleSaveClick}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</StickyCard>
|
||||
|
||||
<form
|
||||
bind:this={mainFormElement}
|
||||
method="POST"
|
||||
action={actionUrl}
|
||||
class="md:px-4"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result, update: formUpdate }) => {
|
||||
@@ -302,39 +325,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<!-- Left side: Delete (only in edit mode) -->
|
||||
<div>
|
||||
{#if mode === 'edit'}
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={deleting}
|
||||
icon={deleting ? Loader2 : Trash2}
|
||||
text={deleting ? 'Deleting...' : 'Delete'}
|
||||
on:click={handleDeleteClick}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right side: Cancel and Save -->
|
||||
<div class="flex gap-3">
|
||||
{#if onCancel}
|
||||
<Button
|
||||
variant="secondary"
|
||||
text="Cancel"
|
||||
on:click={onCancel}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving || !isValid || !$isDirty}
|
||||
icon={saving ? Loader2 : Save}
|
||||
text={saving ? (mode === 'create' ? 'Creating...' : 'Saving...') : submitButtonText}
|
||||
on:click={handleSaveClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user