mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-25 04:12:26 +01:00
feat: add scoring page with custom format management
- Implemented server-side loading for scoring data in `+page.server.ts`. - Created a new Svelte component for the scoring page in `+page.svelte`, including UI for managing custom formats, profiles, and display options. - Added a `ScoringTable` component to display custom formats and their scores. - Introduced local storage management for user preferences such as grouping, tiling, and profiles. - Enhanced user experience with modals for information and unsaved changes.
This commit is contained in:
BIN
src/lib/client/assets/nggyu.gif
Normal file
BIN
src/lib/client/assets/nggyu.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
BIN
src/lib/client/assets/thanks.gif
Normal file
BIN
src/lib/client/assets/thanks.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 MiB |
88
src/lib/client/ui/dropdown/CustomGroupManager.svelte
Normal file
88
src/lib/client/ui/dropdown/CustomGroupManager.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
export let customGroups: Array<{ name: string; key: string; tags: string[]; custom: boolean }> = [];
|
||||
export let selectedGroups: Set<string>;
|
||||
export let onAdd: (name: string, tags: string[]) => void;
|
||||
export let onDelete: (key: string) => void;
|
||||
export let onToggle: (key: string) => void;
|
||||
|
||||
let newGroupName = '';
|
||||
let newGroupTags = '';
|
||||
|
||||
function handleSubmit() {
|
||||
if (newGroupName && newGroupTags) {
|
||||
const tags = newGroupTags.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
if (tags.length > 0) {
|
||||
onAdd(newGroupName, tags);
|
||||
newGroupName = '';
|
||||
newGroupTags = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add new group form -->
|
||||
<div class="border-t border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<div class="mb-2 text-xs font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Add Custom Group
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newGroupName}
|
||||
placeholder="Group name"
|
||||
class="block w-full rounded border border-neutral-300 bg-white px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newGroupTags}
|
||||
placeholder="Tags (comma-separated)"
|
||||
class="block w-full rounded border border-neutral-300 bg-white px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
disabled={!newGroupName || !newGroupTags}
|
||||
>
|
||||
Add Group
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Custom groups list -->
|
||||
{#if customGroups.length > 0}
|
||||
<div class="border-t border-neutral-200 dark:border-neutral-700">
|
||||
{#each customGroups as group}
|
||||
<div class="group flex items-center justify-between gap-2 px-4 py-2 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onToggle(group.key)}
|
||||
class="flex flex-1 items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="text-xs font-medium text-neutral-700 dark:text-neutral-300">{group.name}</div>
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{group.tags.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<IconCheckbox
|
||||
checked={selectedGroups.has(group.key)}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => onDelete(group.key)}
|
||||
class="flex h-5 w-5 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="absolute top-full z-40 h-3 w-full"></div>
|
||||
|
||||
<div
|
||||
class="absolute top-full z-50 mt-3 rounded-lg border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800 {positionClass}"
|
||||
class="absolute top-full z-50 mt-3 rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800 {positionClass}"
|
||||
style="min-width: {minWidth}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
58
src/lib/client/ui/form/IconCheckbox.svelte
Normal file
58
src/lib/client/ui/form/IconCheckbox.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export let checked: boolean = false;
|
||||
export let icon: ComponentType;
|
||||
export let color: string = 'blue'; // blue, green, red, or hex color like #FFC230
|
||||
export let shape: 'square' | 'circle' | 'rounded' = 'rounded';
|
||||
export let disabled: boolean = false;
|
||||
|
||||
// Shape classes
|
||||
const shapeClasses: Record<string, string> = {
|
||||
square: 'rounded-none',
|
||||
circle: 'rounded-full',
|
||||
rounded: 'rounded'
|
||||
};
|
||||
|
||||
$: shapeClass = shapeClasses[shape] || shapeClasses.rounded;
|
||||
$: isCustomColor = color.startsWith('#');
|
||||
</script>
|
||||
|
||||
{#if isCustomColor}
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
{disabled}
|
||||
on:click
|
||||
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer focus:outline-none'} {checked
|
||||
? 'hover:brightness-110'
|
||||
: 'bg-neutral-50 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'}"
|
||||
style="background-color: {checked ? color : ''}; border-color: {checked
|
||||
? color
|
||||
: 'rgb(229, 231, 235)'};"
|
||||
>
|
||||
{#if checked}
|
||||
<svelte:component this={icon} size={14} class="text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
{disabled}
|
||||
on:click
|
||||
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
|
||||
? `bg-${color}-600 border-${color}-600 dark:bg-${color}-500 dark:border-${color}-500 hover:brightness-110`
|
||||
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer focus:outline-none'}"
|
||||
>
|
||||
{#if checked}
|
||||
<svelte:component this={icon} size={14} class="text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -10,6 +10,9 @@
|
||||
export let step: number = 1;
|
||||
export let required: boolean = false;
|
||||
export let disabled: boolean = false;
|
||||
export let font: 'mono' | 'sans' | undefined = undefined;
|
||||
|
||||
$: fontClass = font === 'mono' ? 'font-mono' : font === 'sans' ? 'font-sans' : '';
|
||||
|
||||
// Increment/decrement handlers
|
||||
function increment() {
|
||||
@@ -55,7 +58,7 @@
|
||||
{step}
|
||||
{required}
|
||||
{disabled}
|
||||
class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {fontClass}"
|
||||
/>
|
||||
|
||||
<!-- Custom increment/decrement buttons -->
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: if (open) {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
} else {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
$: if (typeof window !== 'undefined') {
|
||||
if (open) {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
} else {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a
|
||||
href={tab.href}
|
||||
data-sveltekit-preload-data="tap"
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {tab.active
|
||||
? 'border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500'
|
||||
: 'border-transparent text-neutral-600 hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50'}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" generics="T extends Record<string, any>">
|
||||
import type { Column } from './types';
|
||||
import type { Column, SortDirection, SortState } from './types';
|
||||
|
||||
/**
|
||||
* Props
|
||||
@@ -10,6 +10,12 @@
|
||||
export let compact: boolean = false;
|
||||
export let emptyMessage: string = 'No data available';
|
||||
export let onRowClick: ((row: T) => void) | undefined = undefined;
|
||||
export let initialSort: SortState | null = null;
|
||||
export let onSortChange: ((sort: SortState | null) => void) | undefined = undefined;
|
||||
|
||||
let sortKey: string | null = initialSort?.key ?? null;
|
||||
let sortDirection: SortDirection = initialSort?.direction ?? 'asc';
|
||||
let sortedData: T[] = data;
|
||||
|
||||
/**
|
||||
* Get cell value by key path (supports nested properties like 'user.name')
|
||||
@@ -31,6 +37,75 @@
|
||||
return 'text-left';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(column: Column<T>) {
|
||||
if (!column.sortable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortKey === column.key) {
|
||||
if (sortDirection === 'asc') {
|
||||
sortDirection = 'desc';
|
||||
} else {
|
||||
sortKey = null;
|
||||
onSortChange?.(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
sortKey = column.key;
|
||||
sortDirection = column.defaultSortDirection ?? 'asc';
|
||||
}
|
||||
|
||||
onSortChange?.(sortKey ? { key: sortKey, direction: sortDirection } : null);
|
||||
}
|
||||
|
||||
function compareValues(a: any, b: any): number {
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return -1;
|
||||
if (b == null) return 1;
|
||||
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
if (a instanceof Date && b instanceof Date) {
|
||||
return a.getTime() - b.getTime();
|
||||
}
|
||||
|
||||
return String(a).localeCompare(String(b));
|
||||
}
|
||||
|
||||
function getSortValue(row: T, column: Column<T>) {
|
||||
if (column.sortAccessor) {
|
||||
return column.sortAccessor(row);
|
||||
}
|
||||
return getCellValue(row, column.key);
|
||||
}
|
||||
|
||||
function sortData(rows: T[]): T[] {
|
||||
if (!sortKey) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const column = columns.find((col) => col.key === sortKey);
|
||||
if (!column) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
if (column.sortComparator) {
|
||||
return column.sortComparator(a, b);
|
||||
}
|
||||
|
||||
const aValue = getSortValue(a, column);
|
||||
const bValue = getSortValue(b, column);
|
||||
return compareValues(aValue, bValue);
|
||||
});
|
||||
|
||||
return sortDirection === 'desc' ? sorted.reverse() : sorted;
|
||||
}
|
||||
|
||||
$: sortedData = sortData(data);
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
@@ -44,12 +119,40 @@
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 ${getAlignClass(column.align)} ${column.width || ''}`}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 {column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : ''}">
|
||||
{#if column.headerIcon}
|
||||
<svelte:component this={column.headerIcon} size={14} />
|
||||
{/if}
|
||||
{column.header}
|
||||
</div>
|
||||
{#if column.sortable}
|
||||
<button
|
||||
type="button"
|
||||
class={`group flex w-full items-center gap-1.5 text-xs font-medium uppercase tracking-wider ${column.align === 'center'
|
||||
? 'justify-center'
|
||||
: column.align === 'right'
|
||||
? 'justify-end'
|
||||
: 'justify-start'}`}
|
||||
on:click={() => toggleSort(column)}
|
||||
>
|
||||
{#if column.headerIcon}
|
||||
<svelte:component this={column.headerIcon} size={14} />
|
||||
{/if}
|
||||
<span>{column.header}</span>
|
||||
<span class="text-[0.6rem] text-neutral-400 transition-opacity group-hover:text-neutral-600 group-hover:dark:text-neutral-200">
|
||||
{#if sortKey === column.key}
|
||||
{sortDirection === 'asc' ? '▲' : '▼'}
|
||||
{:else}
|
||||
⇅
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class={`flex items-center gap-1.5 ${column.align === 'center'
|
||||
? 'justify-center'
|
||||
: column.align === 'right'
|
||||
? 'justify-end'
|
||||
: ''}`}>
|
||||
{#if column.headerIcon}
|
||||
<svelte:component this={column.headerIcon} size={14} />
|
||||
{/if}
|
||||
{column.header}
|
||||
</div>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
<!-- Actions column slot -->
|
||||
@@ -65,7 +168,7 @@
|
||||
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if data.length === 0}
|
||||
{#if sortedData.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={columns.length + ($$slots.actions ? 1 : 0)}
|
||||
@@ -75,7 +178,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each data as row, rowIndex}
|
||||
{#each sortedData as row, rowIndex}
|
||||
<tr
|
||||
class="{hoverable
|
||||
? 'transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900'
|
||||
|
||||
99
src/lib/server/pcd/queries/qualityProfiles/scoring.ts
Normal file
99
src/lib/server/pcd/queries/qualityProfiles/scoring.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Quality profile scoring queries
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
import type { QualityProfileScoring } from '../../types.ts';
|
||||
|
||||
/**
|
||||
* Get quality profile scoring data
|
||||
* Returns all custom formats with their scores per arr type
|
||||
*/
|
||||
export async function scoring(cache: PCDCache, databaseId: number, profileId: number): Promise<QualityProfileScoring> {
|
||||
const db = cache.kb;
|
||||
|
||||
// 1. Get profile settings
|
||||
const profile = await db
|
||||
.selectFrom('quality_profiles')
|
||||
.select(['minimum_custom_format_score', 'upgrade_until_score', 'upgrade_score_increment'])
|
||||
.where('id', '=', profileId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`Quality profile ${profileId} not found`);
|
||||
}
|
||||
|
||||
// 2. Define display arr types (radarr, sonarr) - 'all' is not a column
|
||||
const arrTypes = ['radarr', 'sonarr'];
|
||||
|
||||
// 3. Get all custom formats
|
||||
const customFormats = await db
|
||||
.selectFrom('custom_formats')
|
||||
.select(['id', 'name'])
|
||||
.orderBy('name')
|
||||
.execute();
|
||||
|
||||
// 4. Get all tags for custom formats
|
||||
const customFormatTags = await db
|
||||
.selectFrom('custom_format_tags')
|
||||
.innerJoin('tags', 'tags.id', 'custom_format_tags.tag_id')
|
||||
.select(['custom_format_tags.custom_format_id', 'tags.name as tag_name'])
|
||||
.execute();
|
||||
|
||||
// Build tags map for quick lookup
|
||||
const tagsMap = new Map<number, string[]>();
|
||||
for (const tag of customFormatTags) {
|
||||
if (!tagsMap.has(tag.custom_format_id)) {
|
||||
tagsMap.set(tag.custom_format_id, []);
|
||||
}
|
||||
tagsMap.get(tag.custom_format_id)!.push(tag.tag_name);
|
||||
}
|
||||
|
||||
// 5. Get all scores for this profile
|
||||
const scores = await db
|
||||
.selectFrom('quality_profile_custom_formats')
|
||||
.select(['custom_format_id', 'arr_type', 'score'])
|
||||
.where('quality_profile_id', '=', profileId)
|
||||
.execute();
|
||||
|
||||
// 6. Build scores map for quick lookup
|
||||
const scoresMap = new Map<number, Map<string, number>>();
|
||||
for (const score of scores as { custom_format_id: number; arr_type: string; score: number }[]) {
|
||||
if (!scoresMap.has(score.custom_format_id)) {
|
||||
scoresMap.set(score.custom_format_id, new Map());
|
||||
}
|
||||
scoresMap.get(score.custom_format_id)!.set(score.arr_type, score.score);
|
||||
}
|
||||
|
||||
// 7. Build custom format scoring data
|
||||
const customFormatScoring = customFormats.map((cf: { id: number; name: string }) => {
|
||||
const formatScores: Record<string, number | null> = {};
|
||||
const cfScores = scoresMap.get(cf.id);
|
||||
|
||||
// Get the 'all' score if it exists (used as default for all types)
|
||||
const allScore = cfScores?.get('all') ?? null;
|
||||
|
||||
// Add scores for each arr type
|
||||
// If specific arr type has a score, use it; otherwise use 'all' score
|
||||
for (const arrType of arrTypes) {
|
||||
const specificScore = cfScores?.get(arrType);
|
||||
formatScores[arrType] = specificScore !== undefined ? specificScore : allScore;
|
||||
}
|
||||
|
||||
return {
|
||||
id: cf.id,
|
||||
name: cf.name,
|
||||
tags: tagsMap.get(cf.id) ?? [],
|
||||
scores: formatScores
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
databaseId,
|
||||
arrTypes,
|
||||
customFormats: customFormatScoring,
|
||||
minimum_custom_format_score: profile.minimum_custom_format_score,
|
||||
upgrade_until_score: profile.upgrade_until_score,
|
||||
upgrade_score_increment: profile.upgrade_score_increment
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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, isDataRequest }) => {
|
||||
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');
|
||||
}
|
||||
|
||||
// Always return synchronous data at top level, stream the heavy data
|
||||
if (isDataRequest) {
|
||||
// Client-side navigation - stream the data
|
||||
return {
|
||||
loaded: true, // Synchronous data to enable instant navigation
|
||||
streamed: {
|
||||
scoring: qualityProfileQueries.scoring(cache, currentDatabaseId, profileId)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Initial page load - await the data for SEO
|
||||
const scoringData = await qualityProfileQueries.scoring(cache, currentDatabaseId, profileId);
|
||||
return {
|
||||
loaded: true,
|
||||
streamed: {
|
||||
scoring: Promise.resolve(scoringData)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,927 @@
|
||||
<script lang="ts">
|
||||
import NumberInput from '$ui/form/NumberInput.svelte';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import { Info, ArrowUpDown, ArrowUp, ArrowDown, Layers, Check, LayoutGrid, Settings, User, X } from 'lucide-svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import UnsavedChangesModal from '$ui/modal/UnsavedChangesModal.svelte';
|
||||
import { useUnsavedChanges } from '$lib/client/utils/unsavedChanges.svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import Dropdown from '$ui/dropdown/Dropdown.svelte';
|
||||
import CustomGroupManager from '$ui/dropdown/CustomGroupManager.svelte';
|
||||
import ScoringTable from './components/ScoringTable.svelte';
|
||||
import { createSearchStore } from '$lib/client/stores/search';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const unsavedChanges = useUnsavedChanges();
|
||||
const searchStore = createSearchStore({ debounceMs: 200 });
|
||||
|
||||
let showInfoModal = false;
|
||||
let showOptionsInfoModal = false;
|
||||
|
||||
type SortKey = 'name' | 'radarr' | 'sonarr';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
let sortState: { key: SortKey; direction: SortDirection } | null = null;
|
||||
|
||||
// Tiling
|
||||
let tileColumns: number = 1;
|
||||
const TILING_STORAGE_KEY = 'scoring-tile-columns';
|
||||
|
||||
// General options
|
||||
let hideUnscoredFormats: boolean = false;
|
||||
const HIDE_UNSCORED_STORAGE_KEY = 'scoring-hide-unscored';
|
||||
|
||||
// Grouping
|
||||
type GroupKey = string;
|
||||
let selectedGroups = new Set<GroupKey>();
|
||||
|
||||
const GROUPING_STORAGE_KEY = 'scoring-selected-groups';
|
||||
const CUSTOM_GROUPS_STORAGE_KEY = 'scoring-custom-groups';
|
||||
|
||||
// Define built-in groups with their tags (order matters - first match wins)
|
||||
const builtInGroups = [
|
||||
{ name: 'Audio', key: 'audio' as const, tags: ['audio'], custom: false },
|
||||
{ name: 'HDR/Colour', key: 'hdr' as const, tags: ['hdr', 'colour grade'], custom: false },
|
||||
{ name: 'Release Group', key: 'release-group' as const, tags: ['release group', 'release groups'], custom: false },
|
||||
{ name: 'Release Group Tier', key: 'release-group-tier' as const, tags: ['release group tier', 'release group tiers'], custom: false },
|
||||
{ name: 'Streaming Service', key: 'streaming-service' as const, tags: ['streaming service'], custom: false },
|
||||
{ name: 'Codec', key: 'codec' as const, tags: ['codec'], custom: false },
|
||||
{ name: 'Storage', key: 'storage' as const, tags: ['storage'], custom: false },
|
||||
{ name: 'Source', key: 'source' as const, tags: ['source'], custom: false },
|
||||
{ name: 'Resolution', key: 'resolution' as const, tags: ['sd', '480p', '576p', '720p', '1080p', '2160p', '4k', '8k', 'resolution'], custom: false },
|
||||
{ name: 'Indexer Flag', key: 'indexer-flag' as const, tags: ['flag'], custom: false },
|
||||
{ name: 'Edition', key: 'edition' as const, tags: ['edition'], custom: false },
|
||||
{ name: 'Enhancement', key: 'enhancement' as const, tags: ['enhancement', 'enhancements'], custom: false },
|
||||
{ name: 'Languages', key: 'languages' as const, tags: ['languages'], custom: false }
|
||||
];
|
||||
|
||||
// Custom groups from localStorage
|
||||
let customGroups: Array<{ name: string; key: string; tags: string[]; custom: boolean }> = [];
|
||||
|
||||
// Combined groups
|
||||
$: groups = [...builtInGroups, ...customGroups];
|
||||
|
||||
// Check if current config matches active profile - uncheck if different
|
||||
$: if (currentProfileId && !isLoadingProfile) {
|
||||
const activeProfile = profiles.find(p => p.id === currentProfileId);
|
||||
if (activeProfile) {
|
||||
const searchQuery = $searchStore.query ?? '';
|
||||
const selectedGroupsArray = [...selectedGroups];
|
||||
const customGroupsClean = customGroups.map(({ name, key, tags }) => ({ name, key, tags }));
|
||||
|
||||
// Compare all settings
|
||||
const configChanged =
|
||||
searchQuery !== activeProfile.searchQuery ||
|
||||
JSON.stringify(sortState) !== JSON.stringify(activeProfile.sortState) ||
|
||||
JSON.stringify(selectedGroupsArray) !== JSON.stringify(activeProfile.selectedGroups) ||
|
||||
JSON.stringify(customGroupsClean) !== JSON.stringify(activeProfile.customGroups) ||
|
||||
tileColumns !== activeProfile.tileColumns ||
|
||||
hideUnscoredFormats !== activeProfile.hideUnscoredFormats;
|
||||
|
||||
if (configChanged) {
|
||||
currentProfileId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles
|
||||
type Profile = {
|
||||
id: string;
|
||||
name: string;
|
||||
searchQuery: string;
|
||||
sortState: { key: SortKey; direction: SortDirection } | null;
|
||||
selectedGroups: GroupKey[];
|
||||
customGroups: Array<{ name: string; key: string; tags: string[] }>;
|
||||
tileColumns: number;
|
||||
hideUnscoredFormats: boolean;
|
||||
};
|
||||
let profiles: Profile[] = [];
|
||||
let currentProfileId: string | null = null;
|
||||
let newProfileName = '';
|
||||
let isLoadingProfile = false;
|
||||
const PROFILES_STORAGE_KEY = 'scoring-profiles';
|
||||
|
||||
onMount(() => {
|
||||
// Load custom groups from localStorage
|
||||
const savedCustomGroups = localStorage.getItem(CUSTOM_GROUPS_STORAGE_KEY);
|
||||
if (savedCustomGroups) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedCustomGroups);
|
||||
customGroups = parsed.map((g: any) => ({ ...g, custom: true }));
|
||||
} catch {
|
||||
customGroups = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Load grouping preference from localStorage
|
||||
const saved = localStorage.getItem(GROUPING_STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as GroupKey[];
|
||||
selectedGroups = new Set(parsed.filter(key => groups.some(g => g.key === key)));
|
||||
} catch {
|
||||
selectedGroups = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// Load tiling preference from localStorage
|
||||
const savedTiling = localStorage.getItem(TILING_STORAGE_KEY);
|
||||
if (savedTiling) {
|
||||
const parsed = parseInt(savedTiling, 10);
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= 3) {
|
||||
tileColumns = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Load hide unscored preference from localStorage
|
||||
const savedHideUnscored = localStorage.getItem(HIDE_UNSCORED_STORAGE_KEY);
|
||||
if (savedHideUnscored === 'true') {
|
||||
hideUnscoredFormats = true;
|
||||
}
|
||||
|
||||
// Load profiles from localStorage
|
||||
const savedProfiles = localStorage.getItem(PROFILES_STORAGE_KEY);
|
||||
if (savedProfiles) {
|
||||
try {
|
||||
profiles = JSON.parse(savedProfiles);
|
||||
} catch {
|
||||
profiles = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function saveGroupingPreference() {
|
||||
localStorage.setItem(GROUPING_STORAGE_KEY, JSON.stringify([...selectedGroups]));
|
||||
}
|
||||
|
||||
function toggleGroup(key: GroupKey) {
|
||||
if (selectedGroups.has(key)) {
|
||||
selectedGroups.delete(key);
|
||||
} else {
|
||||
selectedGroups.add(key);
|
||||
}
|
||||
selectedGroups = selectedGroups; // Trigger reactivity
|
||||
saveGroupingPreference();
|
||||
}
|
||||
|
||||
function clearGrouping() {
|
||||
selectedGroups = new Set();
|
||||
saveGroupingPreference();
|
||||
}
|
||||
|
||||
function setTileColumns(columns: number) {
|
||||
tileColumns = columns;
|
||||
localStorage.setItem(TILING_STORAGE_KEY, columns.toString());
|
||||
}
|
||||
|
||||
function toggleHideUnscored() {
|
||||
hideUnscoredFormats = !hideUnscoredFormats;
|
||||
localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString());
|
||||
}
|
||||
|
||||
function saveCustomGroups() {
|
||||
const toSave = customGroups.map(({ name, key, tags }) => ({ name, key, tags }));
|
||||
localStorage.setItem(CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify(toSave));
|
||||
}
|
||||
|
||||
function addCustomGroup(name: string, tags: string[]) {
|
||||
const key = `custom-${Date.now()}`;
|
||||
customGroups = [...customGroups, { name, key, tags, custom: true }];
|
||||
saveCustomGroups();
|
||||
}
|
||||
|
||||
function deleteCustomGroup(key: string) {
|
||||
customGroups = customGroups.filter((g) => g.key !== key);
|
||||
// Remove from selected groups if it was selected
|
||||
selectedGroups.delete(key);
|
||||
selectedGroups = selectedGroups;
|
||||
saveCustomGroups();
|
||||
saveGroupingPreference();
|
||||
}
|
||||
|
||||
// Profile management
|
||||
function saveProfile(name: string) {
|
||||
const id = `profile-${Date.now()}`;
|
||||
const profile: Profile = {
|
||||
id,
|
||||
name,
|
||||
searchQuery: $searchStore.query ?? '',
|
||||
sortState,
|
||||
selectedGroups: [...selectedGroups],
|
||||
customGroups: customGroups.map(({ name, key, tags }) => ({ name, key, tags })),
|
||||
tileColumns,
|
||||
hideUnscoredFormats
|
||||
};
|
||||
profiles = [...profiles, profile];
|
||||
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles));
|
||||
currentProfileId = id;
|
||||
}
|
||||
|
||||
function loadProfile(id: string) {
|
||||
const profile = profiles.find(p => p.id === id);
|
||||
if (!profile) return;
|
||||
|
||||
isLoadingProfile = true;
|
||||
|
||||
// Apply profile settings
|
||||
searchStore.setQuery(profile.searchQuery);
|
||||
sortState = profile.sortState as { key: SortKey; direction: SortDirection } | null;
|
||||
selectedGroups = new Set(profile.selectedGroups);
|
||||
customGroups = profile.customGroups.map(g => ({ ...g, custom: true }));
|
||||
tileColumns = profile.tileColumns;
|
||||
hideUnscoredFormats = profile.hideUnscoredFormats;
|
||||
|
||||
// Save to individual storage keys
|
||||
saveGroupingPreference();
|
||||
saveCustomGroups();
|
||||
localStorage.setItem(TILING_STORAGE_KEY, tileColumns.toString());
|
||||
localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString());
|
||||
|
||||
currentProfileId = id;
|
||||
isLoadingProfile = false;
|
||||
}
|
||||
|
||||
function deleteProfile(id: string) {
|
||||
profiles = profiles.filter(p => p.id !== id);
|
||||
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles));
|
||||
if (currentProfileId === id) {
|
||||
currentProfileId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveProfile() {
|
||||
if (newProfileName.trim()) {
|
||||
saveProfile(newProfileName.trim());
|
||||
newProfileName = '';
|
||||
}
|
||||
}
|
||||
|
||||
function loadDefaultProfile() {
|
||||
isLoadingProfile = true;
|
||||
|
||||
// Apply default settings
|
||||
searchStore.setQuery('');
|
||||
sortState = { key: 'radarr', direction: 'desc' };
|
||||
selectedGroups = new Set();
|
||||
customGroups = [];
|
||||
tileColumns = 1;
|
||||
hideUnscoredFormats = false;
|
||||
|
||||
// Save to individual storage keys
|
||||
saveGroupingPreference();
|
||||
saveCustomGroups();
|
||||
localStorage.setItem(TILING_STORAGE_KEY, tileColumns.toString());
|
||||
localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString());
|
||||
|
||||
currentProfileId = null;
|
||||
isLoadingProfile = false;
|
||||
}
|
||||
|
||||
// Arr type color mapping
|
||||
const arrTypeColors: Record<string, string> = {
|
||||
radarr: '#FFC230',
|
||||
sonarr: '#00CCFF'
|
||||
};
|
||||
|
||||
function getArrTypeColor(arrType: string): string {
|
||||
return arrTypeColors[arrType] || '#3b82f6'; // default to blue
|
||||
}
|
||||
|
||||
// Initialize state based on scoring data
|
||||
function initializeState(scoring: any) {
|
||||
const minimumScore = scoring.minimum_custom_format_score;
|
||||
const upgradeUntilScore = scoring.upgrade_until_score;
|
||||
const upgradeScoreIncrement = scoring.upgrade_score_increment;
|
||||
|
||||
// Custom format scores - create a reactive map
|
||||
const customFormatScores: Record<number, Record<string, number | null>> = {};
|
||||
const customFormatEnabled: Record<number, Record<string, boolean>> = {};
|
||||
|
||||
// Initialize scores and enabled state from data
|
||||
scoring.customFormats.forEach((cf: any) => {
|
||||
customFormatScores[cf.id] = { ...cf.scores };
|
||||
customFormatEnabled[cf.id] = {};
|
||||
scoring.arrTypes.forEach((arrType: string) => {
|
||||
customFormatEnabled[cf.id][arrType] = cf.scores[arrType] !== null;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
minimumScore,
|
||||
upgradeUntilScore,
|
||||
upgradeScoreIncrement,
|
||||
customFormatScores,
|
||||
customFormatEnabled
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSort(key: SortKey, defaultDirection: SortDirection = 'asc') {
|
||||
if (sortState?.key === key) {
|
||||
// Toggle direction
|
||||
sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' };
|
||||
} else {
|
||||
// New sort key
|
||||
sortState = { key, direction: defaultDirection };
|
||||
}
|
||||
}
|
||||
|
||||
function sortFormats(formats: any[], state: any, sortState: { key: SortKey; direction: SortDirection } | null) {
|
||||
if (!sortState) return formats;
|
||||
|
||||
const sorted = [...formats].sort((a, b) => {
|
||||
let aVal: any;
|
||||
let bVal: any;
|
||||
|
||||
if (sortState.key === 'name') {
|
||||
aVal = a.name?.toLowerCase() || '';
|
||||
bVal = b.name?.toLowerCase() || '';
|
||||
return sortState.direction === 'asc'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
} else {
|
||||
// Sort by score (radarr or sonarr)
|
||||
aVal = state.customFormatScores[a.id]?.[sortState.key] ?? null;
|
||||
bVal = state.customFormatScores[b.id]?.[sortState.key] ?? null;
|
||||
|
||||
// Handle nulls - always put them at the end
|
||||
if (aVal === null && bVal === null) return 0;
|
||||
if (aVal === null) return 1;
|
||||
if (bVal === null) return -1;
|
||||
|
||||
return sortState.direction === 'desc'
|
||||
? bVal - aVal
|
||||
: aVal - bVal;
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function groupFormats(formats: any[], selectedGroups: Set<GroupKey>) {
|
||||
// If no groups selected, show all formats in one table
|
||||
if (selectedGroups.size === 0) {
|
||||
return [{ name: null, formats }];
|
||||
}
|
||||
|
||||
const result: { name: string | null; formats: any[] }[] = [];
|
||||
const assigned = new Set<number>();
|
||||
|
||||
// Process each group in order
|
||||
for (const group of groups) {
|
||||
if (!selectedGroups.has(group.key)) continue;
|
||||
|
||||
const groupFormats = formats.filter((format) => {
|
||||
// Skip if already assigned
|
||||
if (assigned.has(format.id)) return false;
|
||||
|
||||
// Check if format has any of the group's tags
|
||||
return format.tags?.some((tag: string) => group.tags.includes(tag.toLowerCase()));
|
||||
});
|
||||
|
||||
// Mark as assigned
|
||||
groupFormats.forEach((f) => assigned.add(f.id));
|
||||
|
||||
result.push({ name: group.name, formats: groupFormats });
|
||||
}
|
||||
|
||||
// Add remaining formats to "Other" group
|
||||
const otherFormats = formats.filter((f) => !assigned.has(f.id));
|
||||
if (otherFormats.length > 0) {
|
||||
result.push({ name: 'Other', formats: otherFormats });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Scoring - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<UnsavedChangesModal />
|
||||
|
||||
{#await data.streamed.scoring}
|
||||
<!-- Loading skeleton -->
|
||||
<div class="mt-6 space-y-6 animate-pulse">
|
||||
<!-- Profile-level Score Settings Skeleton -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="space-y-2">
|
||||
<div class="h-5 w-32 rounded bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div class="h-4 w-48 rounded bg-neutral-100 dark:bg-neutral-800"></div>
|
||||
<div class="h-10 w-full rounded-lg bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Section Header Skeleton -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="h-5 w-48 rounded bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div class="h-4 w-64 rounded bg-neutral-100 dark:bg-neutral-800"></div>
|
||||
</div>
|
||||
<div class="h-8 w-16 rounded-lg bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Table Skeleton -->
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<div class="w-full">
|
||||
<div class="border-b border-neutral-200 bg-neutral-50 p-6 dark:border-neutral-800 dark:bg-neutral-800">
|
||||
<div class="flex gap-4">
|
||||
<div class="h-4 w-48 rounded bg-neutral-300 dark:bg-neutral-600"></div>
|
||||
<div class="h-4 w-24 rounded bg-neutral-300 dark:bg-neutral-600"></div>
|
||||
<div class="h-4 w-24 rounded bg-neutral-300 dark:bg-neutral-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 p-6">
|
||||
{#each [1, 2, 3, 4, 5] as _}
|
||||
<div class="flex gap-4">
|
||||
<div class="h-10 w-48 rounded bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div class="h-10 w-24 rounded bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div class="h-10 w-24 rounded bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:then scoring}
|
||||
{@const state = initializeState(scoring)}
|
||||
{@const searchQuery = ($searchStore.query ?? '').trim().toLowerCase()}
|
||||
{@const filteredCustomFormats = scoring.customFormats.filter((format) => {
|
||||
// Filter by search
|
||||
if (searchQuery && !format.name?.toLowerCase().includes(searchQuery)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter unscored formats if option is enabled
|
||||
if (hideUnscoredFormats) {
|
||||
const hasAnyScore = scoring.arrTypes.some((arrType) => format.scores[arrType] !== null);
|
||||
if (!hasAnyScore) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})}
|
||||
{@const defaultSortKey = (scoring.arrTypes.includes('radarr') ? 'radarr' : scoring.arrTypes[0]) as SortKey}
|
||||
{@const _applyDefaultSort = (() => {
|
||||
if (!sortState && defaultSortKey) {
|
||||
sortState = { key: defaultSortKey, direction: 'desc' };
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{@const sortedCustomFormats = sortFormats(filteredCustomFormats, state, sortState)}
|
||||
{@const groupedFormats = groupFormats(sortedCustomFormats, selectedGroups)}
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Profile-level Score Settings -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="minimumScore"
|
||||
class="block text-sm font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Minimum Score
|
||||
</label>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Minimum custom format score required to download
|
||||
</p>
|
||||
<NumberInput name="minimumScore" bind:value={state.minimumScore} step={1} font="mono" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="upgradeUntilScore"
|
||||
class="block text-sm font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Upgrade Until Score
|
||||
</label>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Stop upgrading when this score is reached
|
||||
</p>
|
||||
<NumberInput
|
||||
name="upgradeUntilScore"
|
||||
bind:value={state.upgradeUntilScore}
|
||||
step={1}
|
||||
font="mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="upgradeScoreIncrement"
|
||||
class="block text-sm font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Upgrade Score Increment
|
||||
</label>
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
Minimum score improvement needed to upgrade
|
||||
</p>
|
||||
<NumberInput
|
||||
name="upgradeScoreIncrement"
|
||||
bind:value={state.upgradeScoreIncrement}
|
||||
step={1}
|
||||
font="mono"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<ActionsBar className="w-full">
|
||||
<SearchAction {searchStore} placeholder="Search custom formats..." />
|
||||
<ActionButton icon={ArrowUpDown} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleSort('name', 'asc')}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {sortState?.key === 'name'
|
||||
? 'bg-neutral-50 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">Name</span>
|
||||
<IconCheckbox
|
||||
checked={sortState?.key === 'name'}
|
||||
icon={sortState?.key === 'name' && sortState.direction === 'desc' ? ArrowDown : ArrowUp}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleSort('radarr', 'desc')}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {sortState?.key === 'radarr'
|
||||
? 'bg-neutral-50 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">Radarr</span>
|
||||
<IconCheckbox
|
||||
checked={sortState?.key === 'radarr'}
|
||||
icon={sortState?.key === 'radarr' && sortState.direction === 'asc' ? ArrowUp : ArrowDown}
|
||||
color={getArrTypeColor('radarr')}
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleSort('sonarr', 'desc')}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {sortState?.key === 'sonarr'
|
||||
? 'bg-neutral-50 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">Sonarr</span>
|
||||
<IconCheckbox
|
||||
checked={sortState?.key === 'sonarr'}
|
||||
icon={sortState?.key === 'sonarr' && sortState.direction === 'asc' ? ArrowUp : ArrowDown}
|
||||
color={getArrTypeColor('sonarr')}
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={Layers} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="14rem">
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
on:click={clearGrouping}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {selectedGroups.size === 0
|
||||
? 'bg-neutral-50 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">No Grouping</span>
|
||||
<IconCheckbox
|
||||
checked={selectedGroups.size === 0}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
{#each builtInGroups as group}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleGroup(group.key)}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {selectedGroups.has(group.key)
|
||||
? 'bg-neutral-50 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">{group.name}</span>
|
||||
<IconCheckbox
|
||||
checked={selectedGroups.has(group.key)}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<CustomGroupManager
|
||||
{customGroups}
|
||||
{selectedGroups}
|
||||
onAdd={addCustomGroup}
|
||||
onDelete={deleteCustomGroup}
|
||||
onToggle={toggleGroup}
|
||||
/>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={LayoutGrid} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
|
||||
<div class="py-1">
|
||||
{#each [1, 2, 3] as columns}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => setTileColumns(columns)}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {tileColumns === columns
|
||||
? 'bg-neutral-50 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">{columns} Column{columns > 1 ? 's' : ''}</span>
|
||||
<IconCheckbox
|
||||
checked={tileColumns === columns}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={Settings} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="14rem">
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
on:click={toggleHideUnscored}
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {hideUnscoredFormats
|
||||
? 'bg-neutral-50 dark:bg-neutral-700'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">Hide Unscored Formats</span>
|
||||
<IconCheckbox
|
||||
checked={hideUnscoredFormats}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={User} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="16rem">
|
||||
<!-- Save current config form -->
|
||||
<div class="border-b border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<div class="mb-2 text-xs font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Save Current Settings
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleSaveProfile} class="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newProfileName}
|
||||
placeholder="Profile name"
|
||||
class="block w-full rounded border border-neutral-300 bg-white px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
disabled={!newProfileName.trim()}
|
||||
>
|
||||
Save Profile
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Saved profiles list -->
|
||||
<div class="py-1">
|
||||
<!-- Default profile option -->
|
||||
<div class="group flex items-center justify-between gap-2 px-4 py-2 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
on:click={loadDefaultProfile}
|
||||
class="flex flex-1 items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="text-xs font-medium text-neutral-700 dark:text-neutral-300">Default</div>
|
||||
</div>
|
||||
<IconCheckbox
|
||||
checked={currentProfileId === null}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if profiles.length > 0}
|
||||
<div class="border-t border-neutral-200 dark:border-neutral-700"></div>
|
||||
{#each profiles as profile}
|
||||
<div class="group flex items-center justify-between gap-2 px-4 py-2 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => loadProfile(profile.id)}
|
||||
class="flex flex-1 items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="text-xs font-medium text-neutral-700 dark:text-neutral-300">{profile.name}</div>
|
||||
</div>
|
||||
<IconCheckbox
|
||||
checked={currentProfileId === profile.id}
|
||||
icon={Check}
|
||||
color="blue"
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => deleteProfile(profile.id)}
|
||||
class="flex h-5 w-5 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
<ActionButton icon={Info} on:click={() => (showOptionsInfoModal = true)} />
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Custom Format Scores Tables -->
|
||||
{#if searchQuery === 'santiagoisthebest'}
|
||||
<div class="flex items-center justify-center rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<img src="/src/lib/client/assets/thanks.gif" alt="Thanks!" class="max-w-full" />
|
||||
</div>
|
||||
{:else if searchQuery === 'rickroll' || searchQuery === 'nevergonnagiveyouup'}
|
||||
<div class="flex items-center justify-center rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<img src="/src/lib/client/assets/nggyu.gif" alt="Never gonna give you up" class="max-w-full" />
|
||||
</div>
|
||||
{:else if sortedCustomFormats.length === 0}
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<div class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{#if scoring.customFormats.length === 0}
|
||||
No custom formats found
|
||||
{:else}
|
||||
No custom formats match your search
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-6"
|
||||
class:grid-cols-1={tileColumns === 1}
|
||||
class:grid-cols-2={tileColumns === 2}
|
||||
class:grid-cols-3={tileColumns === 3}
|
||||
>
|
||||
{#each groupedFormats as group}
|
||||
{#if group.formats.length > 0}
|
||||
<div class="min-w-0">
|
||||
<ScoringTable
|
||||
formats={group.formats}
|
||||
arrTypes={scoring.arrTypes}
|
||||
{state}
|
||||
{getArrTypeColor}
|
||||
title={group.name}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
<InfoModal bind:open={showInfoModal} header="Custom Format Scoring">
|
||||
<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">Custom Format Scores</div>
|
||||
<div class="mt-1">
|
||||
Each custom format can have different scores for different Arr types (Radarr, Sonarr). The
|
||||
score determines how much a release is preferred when it matches the custom format.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Minimum Score</div>
|
||||
<div class="mt-1">
|
||||
The minimum total custom format score required for a release to be downloaded. Releases
|
||||
with scores below this threshold will be rejected.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Upgrade Until Score</div>
|
||||
<div class="mt-1">
|
||||
Once a release reaches this score, the system will stop looking for upgrades. This prevents
|
||||
unnecessary upgrades when you've already got a good quality release.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Upgrade Score Increment
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
The minimum score improvement required to trigger an upgrade. This prevents minor upgrades
|
||||
that don't significantly improve quality.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Positive vs Negative Scores</div>
|
||||
<div class="mt-1">
|
||||
Positive scores increase preference for releases matching the custom format. Negative
|
||||
scores decrease preference or can block releases entirely when combined with the minimum
|
||||
score setting.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<InfoModal bind:open={showOptionsInfoModal} header="Display Options">
|
||||
<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">Search</div>
|
||||
<div class="mt-1">
|
||||
Filter custom formats by name. The search is case-insensitive and matches any part of the format name.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Sort</div>
|
||||
<div class="mt-1">
|
||||
Sort custom formats by Name (A-Z), Radarr score, or Sonarr score. Click the same option again to reverse the sort direction (ascending ↑ or descending ↓). Formats with no score are always shown at the end.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Grouping</div>
|
||||
<div class="mt-1">
|
||||
Organize custom formats into separate tables based on their tags. You can select multiple groups at once. Available groups include Audio, HDR/Colour, Release Group, Codec, Resolution, and more. Formats that don't match any selected group appear in the "Other" table. If a format matches multiple groups, it appears in the first matching group only.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Custom Groups</div>
|
||||
<div class="mt-1">
|
||||
Create your own custom groups by entering a name and comma-separated tags at the bottom of the Grouping dropdown. Your custom groups are saved to your browser and can be deleted at any time. Custom groups work the same as built-in groups - formats are assigned to the first matching group based on their tags.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Tiling</div>
|
||||
<div class="mt-1">
|
||||
Display tables in 1, 2, or 3 columns. This is especially useful when using grouping to view multiple format categories side-by-side.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Hide Unscored Formats</div>
|
||||
<div class="mt-1">
|
||||
Hide custom formats that have no score assigned for any Arr type. This helps focus on formats that are currently being used in your quality profile.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Profiles</div>
|
||||
<div class="mt-1">
|
||||
Save your current display configuration (search, sort, grouping, custom groups, tiling, and options) as a named profile. Load saved profiles to quickly switch between different views. The "Default" profile resets everything to the baseline configuration. Profiles are stored in your browser and automatically uncheck when you modify any settings.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoModal>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
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 state: any;
|
||||
export let getArrTypeColor: (arrType: string) => string;
|
||||
export let title: string | null = null;
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
<div class="mb-3">
|
||||
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{title}</h3>
|
||||
</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 uppercase tracking-wider text-neutral-700 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 uppercase tracking-wider text-neutral-700 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) => !state.customFormatEnabled[format.id]?.[arrType]
|
||||
)}
|
||||
<tr
|
||||
class="transition-colors {rowDisabled
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 opacity-60'
|
||||
: '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={state.customFormatEnabled[format.id][arrType]}
|
||||
icon={Check}
|
||||
color={getArrTypeColor(arrType)}
|
||||
shape="circle"
|
||||
on:click={() => {
|
||||
const isEnabled = state.customFormatEnabled[format.id][arrType];
|
||||
if (isEnabled) {
|
||||
state.customFormatScores[format.id][arrType] = null;
|
||||
} else {
|
||||
if (state.customFormatScores[format.id][arrType] === null) {
|
||||
state.customFormatScores[format.id][arrType] = 0;
|
||||
}
|
||||
}
|
||||
state.customFormatEnabled[format.id][arrType] = !isEnabled;
|
||||
}}
|
||||
/>
|
||||
<div class="w-48">
|
||||
<NumberInput
|
||||
name="score-{format.id}-{arrType}"
|
||||
bind:value={state.customFormatScores[format.id][arrType]}
|
||||
step={1}
|
||||
disabled={!state.customFormatEnabled[format.id][arrType]}
|
||||
font="mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
Reference in New Issue
Block a user