From 78f33aae43122dbc51e1137a35e2c81c9f9fd452 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Sun, 9 Nov 2025 07:07:03 +1100 Subject: [PATCH] feat: implement drag-and-drop functionality for quality page --- .../client/ui/table/ReorderableList.svelte | 112 ++++ .../pcd/queries/qualityProfiles/index.ts | 9 +- .../pcd/queries/qualityProfiles/qualities.ts | 156 +++++ .../[id]/qualities/+page.server.ts | 37 ++ .../[databaseId]/[id]/qualities/+page.svelte | 555 ++++++++++++++++++ 5 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 src/lib/client/ui/table/ReorderableList.svelte create mode 100644 src/lib/server/pcd/queries/qualityProfiles/qualities.ts create mode 100644 src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts create mode 100644 src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte diff --git a/src/lib/client/ui/table/ReorderableList.svelte b/src/lib/client/ui/table/ReorderableList.svelte new file mode 100644 index 0000000..04cb85c --- /dev/null +++ b/src/lib/client/ui/table/ReorderableList.svelte @@ -0,0 +1,112 @@ + + +
+ {#each items as item, index (getKey(item))} +
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" + > + +
+ {/each} +
diff --git a/src/lib/server/pcd/queries/qualityProfiles/index.ts b/src/lib/server/pcd/queries/qualityProfiles/index.ts index 28d6cbb..ba26723 100644 --- a/src/lib/server/pcd/queries/qualityProfiles/index.ts +++ b/src/lib/server/pcd/queries/qualityProfiles/index.ts @@ -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 diff --git a/src/lib/server/pcd/queries/qualityProfiles/qualities.ts b/src/lib/server/pcd/queries/qualityProfiles/qualities.ts new file mode 100644 index 0000000..887afdb --- /dev/null +++ b/src/lib/server/pcd/queries/qualityProfiles/qualities.ts @@ -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 { + 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(); + 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(); + + // 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()) + }; +} diff --git a/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts new file mode 100644 index 0000000..c3d7219 --- /dev/null +++ b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.server.ts @@ -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 + }; +}; diff --git a/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte new file mode 100644 index 0000000..70ba72c --- /dev/null +++ b/src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte @@ -0,0 +1,555 @@ + + + + Qualities - Profilarr + + + + +
+ + (showInfoModal = true)} /> + (showLegacyQualities = !showLegacyQualities)} + /> + + +
+ +
+

+ Qualities +

+
+ {#if mainBucket.length === 0} +
+ Drop qualities here +
+ {:else} +
+ {#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.referenceId}` : `group-${item.id}`)} +
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} +
+ {/if} + {#if hoverTargetIndex === index && willAddToGroup} +
+ {/if} +
+
+ {#if item.type === 'group' && editingGroupIndex === index} + 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} +
+ {#if item.type === 'group'} + { + 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} + + (Group) + {:else} + {item.name} + {/if} +
+ {#if item.type === 'group' && item.members} +
+ {item.members.map(m => m.name).join(', ')} +
+ {/if} + {/if} +
+
+ {#if hoverTargetIndex === index && willCreateGroup} +
+ Create Group +
+ {:else if hoverTargetIndex === index && willAddToGroup} +
+ Add to Group +
+ {/if} + {#if item.type === 'group'} + + {/if} + { + e.stopPropagation(); + toggleUpgradeUntil(index); + }} + /> + { + e.stopPropagation(); + toggleEnabled(index); + }} + /> +
+
+
+ {/each} +
+
+ {/if} +
+
+ + + {#if showLegacyQualities} +
+

+ Unmigrated Qualities +

+
+ {#if legacyBucket.length === 0} +
+ All qualities added! +
+ {:else} +
+ {#each legacyBucket as quality} +
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" + > +
+ {quality.name} +
+
+ {/each} +
+ {/if} +
+
+ {/if} +
+
+ + +
+
+
Qualities
+
+ 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. +
+
+ +
+
Reordering
+
+ 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. +
+
+ +
+
Creating Groups
+
+ 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. +
+
+ +
+
Group Names
+
+ 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. +
+
+ +
+
Collapsing Groups
+
+ Click the X button on a group to break it apart into individual qualities. The qualities will maintain their enabled state and position. +
+
+ +
+
Enabled/Disabled
+
+ 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. +
+
+ +
+
Upgrade Until
+
+ 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. +
+
+ +
+
Unmigrated Qualities
+
+ 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. +
+
+
+