feat: implement drag-and-drop functionality for quality page

This commit is contained in:
Sam Chau
2025-11-09 07:07:03 +11:00
parent 53a74a98e8
commit 78f33aae43
5 changed files with 868 additions and 1 deletions

View File

@@ -0,0 +1,112 @@
<script lang="ts" generics="T">
export let items: T[];
export let onReorder: (items: T[]) => void;
export let getKey: (item: T) => string | number;
export let dragGap: string = 'space-y-6';
export let normalGap: string = 'space-y-4';
export let sensitivity: number = 0.3;
let draggedItemIndex: number | null = null;
let lastTargetIndex: number | null = null;
function handleDragStart(index: number) {
draggedItemIndex = index;
lastTargetIndex = index;
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
// Handle dragging way above or below the list
if (draggedItemIndex !== null) {
const container = e.currentTarget as HTMLElement;
const rect = container.getBoundingClientRect();
// If way above, move to top
if (e.clientY < rect.top + 50) {
if (lastTargetIndex !== 0) {
moveItemToPosition(0);
}
return;
}
// If way below, move to bottom
if (e.clientY > rect.bottom - 50) {
const lastIndex = items.length - 1;
if (lastTargetIndex !== lastIndex) {
moveItemToPosition(lastIndex);
}
return;
}
}
}
function handleItemDragOver(e: DragEvent, targetIndex: number) {
e.preventDefault();
if (draggedItemIndex !== null) {
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const itemHeight = rect.height;
// If we're over a different item than our last target, require being in the middle zone
if (lastTargetIndex !== targetIndex) {
// Require being at least sensitivity% into the item before swapping to a NEW position
if (relativeY < itemHeight * sensitivity || relativeY > itemHeight * (1 - sensitivity)) {
return;
}
}
// If we're over the same item we last swapped to, we're already in the right position
// Don't do anything - this prevents flickering in the dead zones
// Only reorder if we've moved to a different item
if (lastTargetIndex === targetIndex) return;
moveItemToPosition(targetIndex);
}
}
function moveItemToPosition(targetIndex: number) {
if (draggedItemIndex === null) return;
lastTargetIndex = targetIndex;
const newItems = [...items];
const [movedItem] = newItems.splice(draggedItemIndex, 1);
newItems.splice(targetIndex, 0, movedItem);
items = newItems;
onReorder(newItems);
draggedItemIndex = targetIndex; // Update the dragged index
}
function handleItemDrop(e: DragEvent, targetIndex: number) {
e.preventDefault();
e.stopPropagation();
draggedItemIndex = null;
}
function handleDragEnd() {
draggedItemIndex = null;
lastTargetIndex = null;
}
</script>
<div class="{draggedItemIndex !== null ? dragGap : normalGap} transition-all duration-100" on:dragover={handleDragOver} role="list">
{#each items as item, index (getKey(item))}
<div
draggable={true}
on:dragstart={() => handleDragStart(index)}
on:dragover={(e) => handleItemDragOver(e, index)}
on:drop={(e) => handleItemDrop(e, index)}
on:dragend={handleDragEnd}
class="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 {draggedItemIndex === index ? 'opacity-50 scale-95' : ''}"
style="transition: opacity 100ms, transform 100ms;"
role="listitem"
>
<slot {item} {index} />
</div>
{/each}
</div>

View File

@@ -16,9 +16,16 @@ export type {
QualityProfileTableRow
} from './types.ts';
export type {
QualityMember,
OrderedItem,
QualityGroup as QualitiesGroup,
QualitiesPageData
} from './qualities.ts';
// Export query functions
export { list } from './list.ts';
export { general } from './general.ts';
export { languages } from './languages.ts';
export { qualities } from './qualities.ts';
export { scoring } from './scoring.ts';
// TODO: qualities function needs to be rewritten

View File

@@ -0,0 +1,156 @@
/**
* Quality profile qualities queries
*/
import type { PCDCache } from '../../cache.ts';
export interface QualityMember {
id: number;
name: string;
}
export interface OrderedItem {
id: number; // quality_profile_qualities.id
type: 'quality' | 'group';
referenceId: number; // quality_id or quality_group_id
name: string;
position: number;
enabled: boolean;
upgradeUntil: boolean;
members?: QualityMember[];
}
export interface QualityGroup {
id: number;
name: string;
members: QualityMember[];
}
export interface QualitiesPageData {
orderedItems: OrderedItem[];
availableQualities: QualityMember[];
allQualities: QualityMember[];
groups: QualityGroup[];
}
/**
* Get quality profile qualities data
*/
export async function qualities(cache: PCDCache, _databaseId: number, profileId: number): Promise<QualitiesPageData> {
const db = cache.kb;
// 1. Get all qualities
const allQualities = await db
.selectFrom('qualities')
.select(['id', 'name'])
.orderBy('name')
.execute();
// 2. Get all groups for this profile
const groups = await db
.selectFrom('quality_groups')
.select(['id', 'name'])
.where('quality_profile_id', '=', profileId)
.execute();
// 3. Get group members
const groupMembers = await db
.selectFrom('quality_group_members')
.innerJoin('qualities', 'qualities.id', 'quality_group_members.quality_id')
.innerJoin('quality_groups', 'quality_groups.id', 'quality_group_members.quality_group_id')
.where('quality_groups.quality_profile_id', '=', profileId)
.select([
'quality_group_members.quality_group_id',
'qualities.id as quality_id',
'qualities.name as quality_name'
])
.execute();
// Build groups with members
const groupsMap = new Map<number, QualityGroup>();
for (const group of groups) {
groupsMap.set(group.id, {
id: group.id,
name: group.name,
members: []
});
}
for (const member of groupMembers) {
const group = groupsMap.get(member.quality_group_id);
if (group) {
group.members.push({
id: member.quality_id,
name: member.quality_name
});
}
}
// 4. Get ordered list (quality_profile_qualities)
const orderedList = await db
.selectFrom('quality_profile_qualities')
.leftJoin('qualities', 'qualities.id', 'quality_profile_qualities.quality_id')
.leftJoin('quality_groups', 'quality_groups.id', 'quality_profile_qualities.quality_group_id')
.where('quality_profile_qualities.quality_profile_id', '=', profileId)
.select([
'quality_profile_qualities.id',
'quality_profile_qualities.quality_id',
'quality_profile_qualities.quality_group_id',
'quality_profile_qualities.position',
'quality_profile_qualities.enabled',
'quality_profile_qualities.upgrade_until',
'qualities.name as quality_name',
'quality_groups.name as group_name'
])
.orderBy('quality_profile_qualities.position')
.execute();
// Build ordered items
const orderedItems: OrderedItem[] = orderedList.map(item => {
const isGroup = item.quality_group_id !== null;
const referenceId = isGroup ? item.quality_group_id! : item.quality_id!;
const name = isGroup ? item.group_name! : item.quality_name!;
const orderedItem: OrderedItem = {
id: item.id,
type: isGroup ? 'group' : 'quality',
referenceId,
name,
position: item.position,
enabled: item.enabled === 1,
upgradeUntil: item.upgrade_until === 1
};
// Add members if it's a group
if (isGroup) {
const group = groupsMap.get(referenceId);
orderedItem.members = group?.members || [];
}
return orderedItem;
});
// 5. Find available qualities (not in ordered list and not in any group)
const usedQualityIds = new Set<number>();
// Mark qualities in ordered list
for (const item of orderedItems) {
if (item.type === 'quality') {
usedQualityIds.add(item.referenceId);
} else {
// Mark all members of groups as used
item.members?.forEach(member => usedQualityIds.add(member.id));
}
}
const availableQualities = allQualities
.filter(q => !usedQualityIds.has(q.id))
.map(q => ({ id: q.id, name: q.name }));
return {
orderedItems,
availableQualities,
allQualities: allQualities.map(q => ({ id: q.id, name: q.name })),
groups: Array.from(groupsMap.values())
};
}

View File

@@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
// Validate params exist
if (!databaseId || !id) {
throw error(400, 'Missing required parameters');
}
// Parse and validate the database ID
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
// Parse and validate the profile ID
const profileId = parseInt(id, 10);
if (isNaN(profileId)) {
throw error(400, 'Invalid profile ID');
}
// Get the cache for the database
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
const qualitiesData = await qualityProfileQueries.qualities(cache, currentDatabaseId, profileId);
return {
qualities: qualitiesData
};
};

View File

@@ -0,0 +1,555 @@
<script lang="ts">
import { X, Check, ArrowUp, Info, Eye, EyeOff } from 'lucide-svelte';
import IconCheckbox from '$lib/client/ui/form/IconCheckbox.svelte';
import UnsavedChangesModal from '$ui/modal/UnsavedChangesModal.svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import { alertStore } from '$lib/client/alerts/store';
import { useUnsavedChanges } from '$lib/client/utils/unsavedChanges.svelte';
import type { PageData } from './$types';
export let data: PageData;
const unsavedChanges = useUnsavedChanges();
let showInfoModal = false;
let showLegacyQualities = true;
type OrderedItem = PageData['qualities']['orderedItems'][number];
type QualityMember = PageData['qualities']['availableQualities'][number];
let mainBucket: OrderedItem[] = structuredClone(data.qualities.orderedItems);
let legacyBucket: QualityMember[] = structuredClone(data.qualities.availableQualities);
let draggedFromLegacy: QualityMember | null = null;
let draggedQualityFromMain: { item: OrderedItem; index: number } | null = null;
let lastTargetIndex: number | null = null;
let hoverTargetIndex: number | null = null;
let willCreateGroup: boolean = false;
let willAddToGroup: boolean = false;
let editingGroupIndex: number | null = null;
let editingGroupName: string = '';
let groupingMode: boolean = false; // For mobile toggle
// Mark as dirty when mainBucket or legacyBucket changes
$: if (
JSON.stringify(mainBucket) !== JSON.stringify(data.qualities.orderedItems) ||
JSON.stringify(legacyBucket) !== JSON.stringify(data.qualities.availableQualities)
) {
unsavedChanges.markDirty();
}
function handleQualityDragStart(item: OrderedItem, index: number) {
draggedQualityFromMain = { item, index };
}
function handleQualityDragOver(e: DragEvent, targetItem: OrderedItem, targetIndex: number) {
e.preventDefault();
if (!draggedQualityFromMain || draggedQualityFromMain.index === targetIndex) {
hoverTargetIndex = null;
willCreateGroup = false;
willAddToGroup = false;
return;
}
const draggedItem = draggedQualityFromMain.item;
const isGroupingMode = e.ctrlKey || e.metaKey || groupingMode;
hoverTargetIndex = targetIndex;
// Grouping mode (Ctrl/Cmd held or mobile toggle enabled)
if (isGroupingMode && draggedItem.type === 'quality') {
// Check if we're hovering over a quality with a quality (create group)
if (targetItem.type === 'quality') {
willCreateGroup = true;
willAddToGroup = false;
}
// Check if we're hovering over a group with a quality (add to group)
else if (targetItem.type === 'group') {
willCreateGroup = false;
willAddToGroup = true;
} else {
willCreateGroup = false;
willAddToGroup = false;
}
}
// Reordering mode (default)
else {
willCreateGroup = false;
willAddToGroup = false;
// Only reorder if we've moved to a different item
if (lastTargetIndex === targetIndex) return;
lastTargetIndex = targetIndex;
const newBucket = [...mainBucket];
const sourceIndex = draggedQualityFromMain.index;
const [movedItem] = newBucket.splice(sourceIndex, 1);
newBucket.splice(targetIndex, 0, movedItem);
// Recalculate positions
newBucket.forEach((item, index) => {
item.position = index;
});
mainBucket = newBucket;
draggedQualityFromMain.index = targetIndex; // Update the dragged index
}
}
function handleQualityDragLeave() {
hoverTargetIndex = null;
willCreateGroup = false;
willAddToGroup = false;
}
function handleQualityDrop(e: DragEvent, targetItem: OrderedItem, targetIndex: number) {
e.preventDefault();
e.stopPropagation();
if (!draggedQualityFromMain || draggedQualityFromMain.index === targetIndex) {
resetDragState();
return;
}
const draggedItem = draggedQualityFromMain.item;
const sourceIndex = draggedQualityFromMain.index;
const isGroupingMode = e.ctrlKey || e.metaKey || groupingMode;
// Grouping actions (only if Ctrl/Cmd held or grouping mode enabled)
if (isGroupingMode && draggedItem.type === 'quality') {
// Create a new group from two qualities
if (targetItem.type === 'quality') {
const newGroup: OrderedItem = {
id: -1,
type: 'group',
referenceId: -1, // Will be assigned on save
name: `${draggedItem.name} + ${targetItem.name}`,
position: targetIndex,
enabled: draggedItem.enabled && targetItem.enabled,
upgradeUntil: draggedItem.upgradeUntil || targetItem.upgradeUntil,
members: [
{ id: draggedItem.referenceId, name: draggedItem.name },
{ id: targetItem.referenceId, name: targetItem.name }
]
};
const newBucket = [...mainBucket];
// Remove both items
const indicesToRemove = [sourceIndex, targetIndex].sort((a, b) => b - a);
indicesToRemove.forEach(idx => newBucket.splice(idx, 1));
// Insert the new group at the target position
const insertPos = Math.min(sourceIndex, targetIndex);
newBucket.splice(insertPos, 0, newGroup);
// Recalculate positions
newBucket.forEach((item, index) => {
item.position = index;
});
mainBucket = newBucket;
}
// Add quality to existing group
else if (targetItem.type === 'group') {
const newBucket = [...mainBucket];
const targetGroup = newBucket[targetIndex];
// Add the quality to the group's members
if (!targetGroup.members) targetGroup.members = [];
targetGroup.members.push({
id: draggedItem.referenceId,
name: draggedItem.name
});
// Remove the dragged quality
newBucket.splice(sourceIndex, 1);
// Recalculate positions
newBucket.forEach((item, index) => {
item.position = index;
});
mainBucket = newBucket;
}
}
// Reordering was already handled in dragover, just need to clean up
resetDragState();
}
function resetDragState() {
draggedQualityFromMain = null;
lastTargetIndex = null;
hoverTargetIndex = null;
willCreateGroup = false;
willAddToGroup = false;
}
function startEditingGroupName(item: OrderedItem, index: number) {
if (item.type === 'group') {
editingGroupIndex = index;
editingGroupName = item.name;
}
}
function saveGroupName() {
if (editingGroupIndex !== null && editingGroupName.trim()) {
mainBucket[editingGroupIndex].name = editingGroupName.trim();
mainBucket = [...mainBucket]; // Trigger reactivity
}
editingGroupIndex = null;
editingGroupName = '';
}
function cancelEditingGroupName() {
editingGroupIndex = null;
editingGroupName = '';
}
function toggleEnabled(index: number) {
// Block disabling if the item has upgradeUntil set
if (mainBucket[index].enabled && mainBucket[index].upgradeUntil) {
alertStore.add('warning', 'Cannot disable an item that is set as "Upgrade Until". Please remove the "Upgrade Until" flag first.');
return;
}
mainBucket[index].enabled = !mainBucket[index].enabled;
mainBucket = [...mainBucket]; // Trigger reactivity
}
function toggleUpgradeUntil(index: number) {
// Check if the item is enabled, if not enable it and alert
if (!mainBucket[index].enabled) {
mainBucket[index].enabled = true;
alertStore.add('info', 'Item was automatically enabled because "Upgrade Until" requires it to be enabled.');
}
// Set this one and unset all others
mainBucket.forEach((item, i) => {
item.upgradeUntil = i === index;
});
mainBucket = [...mainBucket]; // Trigger reactivity
}
function handleDragStart(item: QualityMember) {
draggedFromLegacy = item;
}
function handleDragOverMain(e: DragEvent) {
e.preventDefault();
}
function handleDropOnMain(e: DragEvent) {
e.preventDefault();
if (!draggedFromLegacy) return;
// Remove from legacy bucket
legacyBucket = legacyBucket.filter(q => q.id !== draggedFromLegacy!.id);
// Add to main bucket at the end
const newItem: OrderedItem = {
id: -1,
type: 'quality',
referenceId: draggedFromLegacy.id,
name: draggedFromLegacy.name,
position: mainBucket.length,
enabled: false,
upgradeUntil: false
};
const newBucket = [...mainBucket, newItem];
// Recalculate positions
newBucket.forEach((item, index) => {
item.position = index;
});
mainBucket = newBucket;
draggedFromLegacy = null;
}
function handleDragEnd() {
draggedFromLegacy = null;
}
function collapseGroup(group: OrderedItem) {
if (group.type !== 'group' || !group.members) return;
// Find the index of the group
const groupIndex = mainBucket.findIndex(item => item.id === group.id);
if (groupIndex === -1) return;
// Create individual quality items from group members
const newQualities: OrderedItem[] = group.members.map((member, index) => ({
id: -1, // Temporary ID
type: 'quality' as const,
referenceId: member.id,
name: member.name,
position: groupIndex + index,
enabled: group.enabled,
upgradeUntil: index === 0 ? group.upgradeUntil : false // Only first gets upgradeUntil
}));
// Remove the group and insert individual qualities
const newBucket = [...mainBucket];
newBucket.splice(groupIndex, 1, ...newQualities);
// Recalculate positions
newBucket.forEach((item, index) => {
item.position = index;
});
mainBucket = newBucket;
}
</script>
<svelte:head>
<title>Qualities - Profilarr</title>
</svelte:head>
<UnsavedChangesModal />
<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>
<div class="grid gap-6" class:grid-cols-2={showLegacyQualities} class:grid-cols-1={!showLegacyQualities}>
<!-- Main Bucket -->
<div>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Qualities
</h2>
<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"
>
{#if mainBucket.length === 0}
<div class="flex h-full items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
Drop qualities here
</div>
{:else}
<div class="space-y-4">
{#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.referenceId}` : `group-${item.id}`)}
<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 ? 'opacity-50 scale-95' : ''}"
style="transition: opacity 100ms, transform 100ms;"
role="button"
tabindex="0"
>
{#if hoverTargetIndex === index && willCreateGroup}
<div class="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 pointer-events-none"></div>
{/if}
{#if hoverTargetIndex === index && willAddToGroup}
<div class="absolute inset-0 rounded-lg border-2 border-dashed border-blue-500 bg-blue-50/30 dark:border-blue-400 dark:bg-blue-950/30 pointer-events-none"></div>
{/if}
<div class="flex items-center justify-between relative">
<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-blue-500 bg-white px-2 py-1 text-sm font-medium text-neutral-900 focus:outline-none focus:ring-2 focus:ring-blue-500 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-blue-600 dark:hover:text-blue-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-blue-600 dark:text-blue-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
bind:checked={item.upgradeUntil}
icon={ArrowUp}
color="#07CA07"
shape="circle"
on:click={(e) => {
e.stopPropagation();
toggleUpgradeUntil(index);
}}
/>
<IconCheckbox
bind:checked={item.enabled}
icon={Check}
color="blue"
shape="circle"
on:click={(e) => {
e.stopPropagation();
toggleEnabled(index);
}}
/>
</div>
</div>
</div>
{/each}
<div class="h-[30px]"></div>
</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>
</div>
{/if}
</div>
</div>
<InfoModal bind:open={showInfoModal} header="Qualities">
<div class="space-y-4 text-sm text-neutral-600 dark:text-neutral-400">
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Qualities</div>
<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.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Reordering</div>
<div class="mt-1">
Drag and drop qualities to change their priority. Higher positions indicate higher preference. The order determines which quality will be selected when multiple options are available.
</div>
</div>
<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.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Group Names</div>
<div class="mt-1">
Click on a group name to edit it. Groups automatically get a default name when created, but you can customize this to better describe the qualities it contains.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Collapsing Groups</div>
<div class="mt-1">
Click the X button on a group to break it apart into individual qualities. The qualities will maintain their enabled state and position.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Enabled/Disabled</div>
<div class="mt-1">
The blue checkbox indicates whether a quality is enabled. Click anywhere on the quality row or the checkbox itself to toggle. Disabled qualities are still tracked for ordering purposes but won't be used for downloads.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Upgrade Until</div>
<div class="mt-1">
The green arrow checkbox marks a quality as the "upgrade until" threshold. Only one quality can have this flag at a time. When a file reaches this quality level, the system will stop looking for upgrades. This quality must be enabled.
</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>