feat: updateLanguages, updateQualities functionality

This commit is contained in:
Sam Chau
2026-01-14 16:03:14 +10:30
parent f4b531b61a
commit aec6d79695
9 changed files with 656 additions and 88 deletions

12
.github/workflows/notify.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Notify
on:
push:
branches:
- "v2"
- "stable"
- "dev"
jobs:
call-notify-commit:
uses: Dictionarry-Hub/parrot/.github/workflows/notify-commit.yml@v1
secrets:
PARROT_URL: ${{ secrets.PARROT_URL }}

View File

@@ -33,4 +33,6 @@ export { scoring } from './scoring.ts';
export { create } from './create.ts';
export { updateGeneral } from './updateGeneral.ts';
export { updateScoring } from './updateScoring.ts';
export { updateQualities } from './updateQualities.ts';
export { updateLanguages } from './updateLanguages.ts';
export { remove } from './remove.ts';

View File

@@ -0,0 +1,72 @@
/**
* Update quality profile languages
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
import { logger } from '$logger/logger.ts';
export interface UpdateLanguagesInput {
languageId: number | null;
type: 'must' | 'only' | 'not' | 'simple';
}
export interface UpdateLanguagesOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
profileId: number;
profileName: string;
input: UpdateLanguagesInput;
}
/**
* Update quality profile language configuration
*/
export async function updateLanguages(options: UpdateLanguagesOptions) {
const { databaseId, cache, layer, profileId, profileName, input } = options;
const db = cache.kb;
const queries = [];
// 1. Delete existing languages for this profile
const deleteLanguages = db
.deleteFrom('quality_profile_languages')
.where('quality_profile_id', '=', profileId)
.compile();
queries.push(deleteLanguages);
// 2. Insert new language if one is selected
if (input.languageId !== null) {
const insertLanguage = {
sql: `INSERT INTO quality_profile_languages (quality_profile_id, language_id, type) VALUES (${profileId}, ${input.languageId}, '${input.type}')`,
parameters: [],
query: {} as never
};
queries.push(insertLanguage);
}
await logger.info(`Save quality profile languages "${profileName}"`, {
source: 'QualityProfile',
meta: {
profileId,
languageId: input.languageId,
type: input.type
}
});
// Write the operation
const result = await writeOperation({
databaseId,
layer,
description: `update-quality-profile-languages-${profileName}`,
queries,
metadata: {
operation: 'update',
entity: 'quality_profile',
name: profileName
}
});
return result;
}

View File

@@ -0,0 +1,143 @@
/**
* Update quality profile qualities
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
import { logger } from '$logger/logger.ts';
export interface QualityMember {
id: number;
name: string;
}
export interface OrderedItem {
id: number;
type: 'quality' | 'group';
referenceId: number;
name: string;
position: number;
enabled: boolean;
upgradeUntil: boolean;
members?: QualityMember[];
}
export interface UpdateQualitiesInput {
orderedItems: OrderedItem[];
}
export interface UpdateQualitiesOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
profileId: number;
profileName: string;
input: UpdateQualitiesInput;
}
function esc(str: string): string {
return str.replace(/'/g, "''");
}
/**
* Update quality profile qualities configuration
*/
export async function updateQualities(options: UpdateQualitiesOptions) {
const { databaseId, cache, layer, profileId, profileName, input } = options;
const db = cache.kb;
const queries = [];
// 1. Delete existing quality_profile_qualities for this profile
const deleteQPQ = db
.deleteFrom('quality_profile_qualities')
.where('quality_profile_id', '=', profileId)
.compile();
queries.push(deleteQPQ);
// 2. Delete existing quality_groups for this profile (cascades to quality_group_members)
const deleteGroups = db
.deleteFrom('quality_groups')
.where('quality_profile_id', '=', profileId)
.compile();
queries.push(deleteGroups);
// 3. Process each ordered item
// First pass: create groups and track their temporary IDs
const groupNameToPosition = new Map<string, number>();
for (const item of input.orderedItems) {
if (item.type === 'group') {
// Create the group
const createGroup = {
sql: `INSERT INTO quality_groups (quality_profile_id, name) VALUES (${profileId}, '${esc(item.name)}')`,
parameters: [],
query: {} as never
};
queries.push(createGroup);
// Track position for this group name
groupNameToPosition.set(item.name, item.position);
// Add group members
if (item.members && item.members.length > 0) {
for (const member of item.members) {
const addMember = {
sql: `INSERT INTO quality_group_members (quality_group_id, quality_id) VALUES ((SELECT id FROM quality_groups WHERE quality_profile_id = ${profileId} AND name = '${esc(item.name)}'), ${member.id})`,
parameters: [],
query: {} as never
};
queries.push(addMember);
}
}
}
}
// 4. Insert quality_profile_qualities entries
for (const item of input.orderedItems) {
const enabled = item.enabled ? 1 : 0;
const upgradeUntil = item.upgradeUntil ? 1 : 0;
if (item.type === 'quality') {
// Individual quality
const insertQPQ = {
sql: `INSERT INTO quality_profile_qualities (quality_profile_id, quality_id, quality_group_id, position, enabled, upgrade_until) VALUES (${profileId}, ${item.referenceId}, NULL, ${item.position}, ${enabled}, ${upgradeUntil})`,
parameters: [],
query: {} as never
};
queries.push(insertQPQ);
} else if (item.type === 'group') {
// Group reference
const insertQPQ = {
sql: `INSERT INTO quality_profile_qualities (quality_profile_id, quality_id, quality_group_id, position, enabled, upgrade_until) VALUES (${profileId}, NULL, (SELECT id FROM quality_groups WHERE quality_profile_id = ${profileId} AND name = '${esc(item.name)}'), ${item.position}, ${enabled}, ${upgradeUntil})`,
parameters: [],
query: {} as never
};
queries.push(insertQPQ);
}
}
await logger.info(`Save quality profile qualities "${profileName}"`, {
source: 'QualityProfile',
meta: {
profileId,
itemCount: input.orderedItems.length,
groupCount: input.orderedItems.filter(i => i.type === 'group').length
}
});
// Write the operation
const result = await writeOperation({
databaseId,
layer,
description: `update-quality-profile-qualities-${profileName}`,
queries,
metadata: {
operation: 'update',
entity: 'quality_profile',
name: profileName
}
});
return result;
}

View File

@@ -2,7 +2,7 @@
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import { page } from '$app/stores';
import { FileText, Gauge, Layers, Earth } from 'lucide-svelte';
import { FileText, Scale, Layers, Earth } from 'lucide-svelte';
$: databaseId = $page.params.databaseId;
$: profileId = $page.params.id;
@@ -19,7 +19,7 @@
label: 'Scoring',
href: `/quality-profiles/${databaseId}/${profileId}/scoring`,
active: currentPath.includes('/scoring'),
icon: Gauge
icon: Scale
},
{
label: 'Qualities',

View File

@@ -1,8 +1,10 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { error, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
import * as languageQueries from '$pcd/queries/languages.ts';
import type { OperationLayer } from '$pcd/writer.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
@@ -38,6 +40,76 @@ export const load: ServerLoad = async ({ params }) => {
return {
languages: languagesData.languages,
availableLanguages
availableLanguages,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
export const actions: Actions = {
update: async ({ request, params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
return fail(400, { error: 'Missing required parameters' });
}
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
return fail(400, { error: 'Invalid database ID' });
}
const profileId = parseInt(id, 10);
if (isNaN(profileId)) {
return fail(400, { error: 'Invalid profile ID' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
const formData = await request.formData();
// Parse form data
const layer = (formData.get('layer') as OperationLayer) || 'user';
const languageIdStr = formData.get('languageId') as string;
const type = (formData.get('type') as 'must' | 'only' | 'not' | 'simple') || 'simple';
const languageId = languageIdStr ? parseInt(languageIdStr, 10) : null;
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
// Get profile name for metadata
const profile = await cache.kb
.selectFrom('quality_profiles')
.select('name')
.where('id', '=', profileId)
.executeTakeFirst();
if (!profile) {
return fail(404, { error: 'Quality profile not found' });
}
// Update the languages
const result = await qualityProfileQueries.updateLanguages({
databaseId: currentDatabaseId,
cache,
layer,
profileId,
profileName: profile.name,
input: {
languageId,
type
}
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to update languages' });
}
return { success: true };
}
};

View File

@@ -1,11 +1,19 @@
<script lang="ts">
import { ChevronDown, Info } from 'lucide-svelte';
import { ChevronDown, Info, Save } from 'lucide-svelte';
import { enhance } from '$app/forms';
import InfoModal from '$ui/modal/InfoModal.svelte';
import type { PageData } from './$types';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import { initEdit, current, isDirty, update } from '$lib/client/stores/dirty';
import type { PageData, ActionData } from './$types';
import type { OperationLayer } from '$pcd/writer';
export let data: PageData;
export let form: ActionData;
let showInfoModal = false;
let showSaveModal = false;
let isSaving = false;
let formElement: HTMLFormElement;
const typeOptions: Array<{ value: 'simple' | 'must' | 'only' | 'not'; label: string }> = [
{ value: 'simple', label: 'Preferred' },
@@ -14,9 +22,20 @@
{ value: 'not', label: 'Does Not Include' }
];
// Initialize with existing language or defaults
let selectedType: 'must' | 'only' | 'not' | 'simple' = data.languages[0]?.type || 'simple';
let selectedLanguageId: number | null = data.languages[0]?.id || null;
// Build initial data from server
$: initialData = {
type: data.languages[0]?.type || 'simple',
languageId: data.languages[0]?.id || null
};
// Initialize dirty tracking
$: initEdit(initialData);
// Reactive getters for current values
$: selectedType = ($current.type ?? 'simple') as 'must' | 'only' | 'not' | 'simple';
$: selectedLanguageId = ($current.languageId ?? null) as number | null;
// Search query tracks the display name
let searchQuery = data.languages[0]?.name || '';
let showTypeDropdown = false;
let showLanguageDropdown = false;
@@ -31,12 +50,12 @@
$: showValidationError = searchQuery !== '' && !isValidLanguage;
function selectType(type: 'must' | 'only' | 'not' | 'simple') {
selectedType = type;
update('type', type);
showTypeDropdown = false;
}
function selectLanguage(language: { id: number; name: string }) {
selectedLanguageId = language.id;
update('languageId', language.id);
searchQuery = language.name;
showLanguageDropdown = false;
}
@@ -50,9 +69,9 @@
l => l.name.toLowerCase() === searchQuery.toLowerCase()
);
if (!exactMatch) {
selectedLanguageId = null;
update('languageId', null);
} else {
selectedLanguageId = exactMatch.id;
update('languageId', exactMatch.id);
}
}
@@ -68,12 +87,47 @@
}
}, 200);
}
function handleSaveClick() {
if (data.canWriteToBase) {
showSaveModal = true;
} else {
submitForm('user');
}
}
function submitForm(layer: OperationLayer) {
showSaveModal = false;
const layerInput = formElement.querySelector('input[name="layer"]') as HTMLInputElement;
if (layerInput) layerInput.value = layer;
formElement.requestSubmit();
}
</script>
<svelte:head>
<title>Languages - Profilarr</title>
</svelte:head>
<form
bind:this={formElement}
method="POST"
action="?/update"
use:enhance={() => {
isSaving = true;
return async ({ update: formUpdate }) => {
await formUpdate();
isSaving = false;
if (form?.success) {
initEdit(initialData);
}
};
}}
>
<input type="hidden" name="layer" value="user" />
<input type="hidden" name="languageId" value={selectedLanguageId ?? ''} />
<input type="hidden" name="type" value={selectedType} />
</form>
<div class="mt-6 space-y-3">
<div class="flex items-start justify-between">
<div>
@@ -84,14 +138,27 @@
Configure the language preference for this profile
</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 class="flex items-center gap-2">
{#if $isDirty}
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving || showValidationError}
class="flex items-center gap-1.5 rounded-lg border border-accent-500 bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
{/if}
<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>
</div>
<div class="flex gap-2">
@@ -187,3 +254,8 @@
</div>
</div>
</InfoModal>
<SaveTargetModal
bind:open={showSaveModal}
onSelect={submitForm}
/>

View File

@@ -1,7 +1,9 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { error, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
import type { OperationLayer } from '$pcd/writer.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
@@ -32,6 +34,95 @@ export const load: ServerLoad = async ({ params }) => {
const qualitiesData = await qualityProfileQueries.qualities(cache, currentDatabaseId, profileId);
return {
qualities: qualitiesData
qualities: qualitiesData,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
export const actions: Actions = {
update: async ({ request, params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
return fail(400, { error: 'Missing required parameters' });
}
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
return fail(400, { error: 'Invalid database ID' });
}
const profileId = parseInt(id, 10);
if (isNaN(profileId)) {
return fail(400, { error: 'Invalid profile ID' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
const formData = await request.formData();
// Parse form data
const layer = (formData.get('layer') as OperationLayer) || 'user';
const orderedItemsJson = formData.get('orderedItems') as string;
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
// Parse ordered items
let orderedItems: Array<{
id: number;
type: 'quality' | 'group';
referenceId: number;
name: string;
position: number;
enabled: boolean;
upgradeUntil: boolean;
members?: Array<{ id: number; name: string }>;
}> = [];
try {
orderedItems = JSON.parse(orderedItemsJson || '[]');
} catch {
return fail(400, { error: 'Invalid ordered items format' });
}
// Validate: only one item can have upgradeUntil set to true
const upgradeUntilCount = orderedItems.filter(item => item.upgradeUntil).length;
if (upgradeUntilCount > 1) {
return fail(400, { error: 'Only one quality can be marked as "upgrade until"' });
}
// Get profile name for metadata
const profile = await cache.kb
.selectFrom('quality_profiles')
.select('name')
.where('id', '=', profileId)
.executeTakeFirst();
if (!profile) {
return fail(404, { error: 'Quality profile not found' });
}
// Update the qualities
const result = await qualityProfileQueries.updateQualities({
databaseId: currentDatabaseId,
cache,
layer,
profileId,
profileName: profile.name,
input: {
orderedItems
}
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to update qualities' });
}
return { success: true };
}
};

View File

@@ -1,10 +1,20 @@
<script lang="ts">
import { X, Check, ArrowUp, Info, Eye, EyeOff } from 'lucide-svelte';
import { tick } from 'svelte';
import { X, Check, ArrowUp, Info, Eye, EyeOff, Save, Loader2 } from 'lucide-svelte';
import IconCheckbox from '$lib/client/ui/form/IconCheckbox.svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.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;
@@ -15,9 +25,37 @@
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);
// Form data shape
interface QualitiesFormData {
orderedItems: OrderedItem[];
availableQualities: QualityMember[];
}
// Build initial data from server
$: initialData = buildInitialData(data.qualities);
function buildInitialData(qualities: typeof data.qualities): QualitiesFormData {
return {
orderedItems: structuredClone(qualities.orderedItems),
availableQualities: structuredClone(qualities.availableQualities)
};
}
// Initialize dirty tracking
$: initEdit(initialData);
// 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;
// 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;
@@ -26,7 +64,17 @@
let willAddToGroup: boolean = false;
let editingGroupIndex: number | null = null;
let editingGroupName: string = '';
let groupingMode: boolean = false; // For mobile toggle
let groupingMode: boolean = false;
// 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 };
@@ -47,30 +95,22 @@
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') {
} else if (targetItem.type === 'group') {
willCreateGroup = false;
willAddToGroup = true;
} else {
willCreateGroup = false;
willAddToGroup = false;
}
}
// Reordering mode (default)
else {
} else {
willCreateGroup = false;
willAddToGroup = false;
// Only reorder if we've moved to a different item
if (lastTargetIndex === targetIndex) return;
lastTargetIndex = targetIndex;
const newBucket = [...mainBucket];
@@ -78,13 +118,12 @@
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
updateMainBucket(newBucket);
draggedQualityFromMain.index = targetIndex;
}
}
@@ -107,14 +146,12 @@
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
referenceId: -1,
name: `${draggedItem.name} + ${targetItem.name}`,
position: targetIndex,
enabled: draggedItem.enabled && targetItem.enabled,
@@ -126,42 +163,34 @@
};
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') {
updateMainBucket(newBucket);
} else if (targetItem.type === 'group') {
const newBucket = [...mainBucket];
const targetGroup = newBucket[targetIndex];
const targetGroup = { ...newBucket[targetIndex] };
// Add the quality to the group's members
if (!targetGroup.members) targetGroup.members = [];
targetGroup.members.push({
targetGroup.members = [...targetGroup.members, {
id: draggedItem.referenceId,
name: draggedItem.name
});
}];
// Remove the dragged quality
newBucket[targetIndex] = targetGroup;
newBucket.splice(sourceIndex, 1);
// Recalculate positions
newBucket.forEach((item, index) => {
item.position = index;
});
mainBucket = newBucket;
updateMainBucket(newBucket);
}
}
// Reordering was already handled in dragover, just need to clean up
resetDragState();
}
@@ -183,8 +212,9 @@
function saveGroupName() {
if (editingGroupIndex !== null && editingGroupName.trim()) {
mainBucket[editingGroupIndex].name = editingGroupName.trim();
mainBucket = [...mainBucket]; // Trigger reactivity
const newBucket = [...mainBucket];
newBucket[editingGroupIndex] = { ...newBucket[editingGroupIndex], name: editingGroupName.trim() };
updateMainBucket(newBucket);
}
editingGroupIndex = null;
editingGroupName = '';
@@ -196,28 +226,28 @@
}
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
const newBucket = [...mainBucket];
newBucket[index] = { ...newBucket[index], enabled: !newBucket[index].enabled };
updateMainBucket(newBucket);
}
function toggleUpgradeUntil(index: number) {
// Check if the item is enabled, if not enable it and alert
if (!mainBucket[index].enabled) {
mainBucket[index].enabled = true;
const newBucket = [...mainBucket];
if (!newBucket[index].enabled) {
newBucket[index] = { ...newBucket[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;
newBucket.forEach((item, i) => {
newBucket[i] = { ...item, upgradeUntil: i === index };
});
mainBucket = [...mainBucket]; // Trigger reactivity
updateMainBucket(newBucket);
}
function handleDragStart(item: QualityMember) {
@@ -232,10 +262,9 @@
e.preventDefault();
if (!draggedFromLegacy) return;
// Remove from legacy bucket
legacyBucket = legacyBucket.filter(q => q.id !== draggedFromLegacy!.id);
const newLegacyBucket = legacyBucket.filter(q => q.id !== draggedFromLegacy!.id);
updateLegacyBucket(newLegacyBucket);
// Add to main bucket at the end
const newItem: OrderedItem = {
id: -1,
type: 'quality',
@@ -247,11 +276,10 @@
};
const newBucket = [...mainBucket, newItem];
// Recalculate positions
newBucket.forEach((item, index) => {
item.position = index;
});
mainBucket = newBucket;
updateMainBucket(newBucket);
draggedFromLegacy = null;
}
@@ -263,31 +291,44 @@
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);
const groupIndex = mainBucket.findIndex(item => item.id === group.id && item.type === 'group' && item.name === group.name);
if (groupIndex === -1) return;
// Create individual quality items from group members
const newQualities: OrderedItem[] = group.members.map((member, index) => ({
id: -1, // Temporary ID
id: -1,
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
upgradeUntil: index === 0 ? group.upgradeUntil : false
}));
// 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;
updateMainBucket(newBucket);
}
async function handleSaveClick() {
if (data.canWriteToBase) {
showSaveTargetModal = true;
} else {
selectedLayer = 'user';
await tick();
formElement?.requestSubmit();
}
}
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
formElement?.requestSubmit();
}
</script>
@@ -295,6 +336,59 @@
<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}
</div>
<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)} />
@@ -323,7 +417,7 @@
</div>
{:else}
<div class="space-y-4">
{#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.referenceId}` : `group-${item.id}`)}
{#each mainBucket as item, index (item.type === 'quality' ? `quality-${item.referenceId}-${index}` : `group-${item.name}-${index}`)}
<div
draggable={true}
on:dragstart={() => handleQualityDragStart(item, index)}
@@ -414,7 +508,7 @@
</button>
{/if}
<IconCheckbox
bind:checked={item.upgradeUntil}
checked={item.upgradeUntil}
icon={ArrowUp}
color="#07CA07"
shape="circle"
@@ -424,7 +518,7 @@
}}
/>
<IconCheckbox
bind:checked={item.enabled}
checked={item.enabled}
icon={Check}
color="blue"
shape="circle"
@@ -539,3 +633,13 @@
</div>
</div>
</InfoModal>
<!-- Save Target Modal -->
{#if data.canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
{/if}