mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-26 20:59:13 +01:00
feat(quality-profiles): add quality profile management with views and queries
This commit is contained in:
245
src/lib/server/pcd/queries/qualityProfiles.ts
Normal file
245
src/lib/server/pcd/queries/qualityProfiles.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Quality Profile queries for PCD cache
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../cache.ts';
|
||||
import type { Tag } from '../types.ts';
|
||||
import { parseMarkdown } from '$utils/markdown/markdown.ts';
|
||||
|
||||
// Type for quality/group items in the hierarchy
|
||||
interface QualityItem {
|
||||
position: number;
|
||||
type: 'quality' | 'group';
|
||||
id: number;
|
||||
name: string;
|
||||
is_upgrade_until: boolean;
|
||||
}
|
||||
|
||||
// Type for language configuration
|
||||
interface ProfileLanguage {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'must' | 'only' | 'not' | 'simple';
|
||||
}
|
||||
|
||||
// Type for custom format counts
|
||||
interface CustomFormatCounts {
|
||||
all: number;
|
||||
radarr: number;
|
||||
sonarr: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality profile data for table view with all relationships
|
||||
*/
|
||||
export interface QualityProfileTableRow {
|
||||
// Basic info
|
||||
id: number;
|
||||
name: string;
|
||||
description: string; // Parsed HTML from markdown
|
||||
|
||||
// Tags
|
||||
tags: Tag[];
|
||||
|
||||
// Upgrade settings
|
||||
upgrades_allowed: boolean;
|
||||
minimum_custom_format_score: number;
|
||||
upgrade_until_score?: number; // Only if upgrades_allowed
|
||||
upgrade_score_increment?: number; // Only if upgrades_allowed
|
||||
|
||||
// Custom format counts by arr type
|
||||
custom_formats: CustomFormatCounts;
|
||||
|
||||
// Quality hierarchy (in order)
|
||||
qualities: QualityItem[];
|
||||
|
||||
// Single language configuration
|
||||
language?: ProfileLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality profiles with full data for table/card views
|
||||
* Optimized to minimize database queries
|
||||
*/
|
||||
export function list(cache: PCDCache): QualityProfileTableRow[] {
|
||||
// 1. Get all quality profiles
|
||||
const profiles = cache.query<{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
upgrades_allowed: number;
|
||||
minimum_custom_format_score: number;
|
||||
upgrade_until_score: number;
|
||||
upgrade_score_increment: number;
|
||||
}>(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
upgrades_allowed,
|
||||
minimum_custom_format_score,
|
||||
upgrade_until_score,
|
||||
upgrade_score_increment
|
||||
FROM quality_profiles
|
||||
ORDER BY name
|
||||
`);
|
||||
|
||||
if (profiles.length === 0) return [];
|
||||
|
||||
const profileIds = profiles.map(p => p.id);
|
||||
const idPlaceholders = profileIds.map(() => '?').join(',');
|
||||
|
||||
// 2. Get all tags for all profiles in one query
|
||||
const allTags = cache.query<{
|
||||
quality_profile_id: number;
|
||||
tag_id: number;
|
||||
tag_name: string;
|
||||
tag_created_at: string;
|
||||
}>(`
|
||||
SELECT
|
||||
qpt.quality_profile_id,
|
||||
t.id as tag_id,
|
||||
t.name as tag_name,
|
||||
t.created_at as tag_created_at
|
||||
FROM quality_profile_tags qpt
|
||||
JOIN tags t ON qpt.tag_id = t.id
|
||||
WHERE qpt.quality_profile_id IN (${idPlaceholders})
|
||||
ORDER BY qpt.quality_profile_id, t.name
|
||||
`, ...profileIds);
|
||||
|
||||
// 3. Get custom format counts grouped by arr_type for all profiles
|
||||
const formatCounts = cache.query<{
|
||||
quality_profile_id: number;
|
||||
arr_type: string;
|
||||
count: number;
|
||||
}>(`
|
||||
SELECT
|
||||
quality_profile_id,
|
||||
arr_type,
|
||||
COUNT(*) as count
|
||||
FROM quality_profile_custom_formats
|
||||
WHERE quality_profile_id IN (${idPlaceholders})
|
||||
GROUP BY quality_profile_id, arr_type
|
||||
`, ...profileIds);
|
||||
|
||||
// 4. Get all qualities for all profiles with names
|
||||
const allQualities = cache.query<{
|
||||
quality_profile_id: number;
|
||||
position: number;
|
||||
upgrade_until: number;
|
||||
quality_id: number | null;
|
||||
quality_group_id: number | null;
|
||||
quality_name: string | null;
|
||||
group_name: string | null;
|
||||
}>(`
|
||||
SELECT
|
||||
qpq.quality_profile_id,
|
||||
qpq.position,
|
||||
qpq.upgrade_until,
|
||||
qpq.quality_id,
|
||||
qpq.quality_group_id,
|
||||
q.name as quality_name,
|
||||
qg.name as group_name
|
||||
FROM quality_profile_qualities qpq
|
||||
LEFT JOIN qualities q ON qpq.quality_id = q.id
|
||||
LEFT JOIN quality_groups qg ON qpq.quality_group_id = qg.id
|
||||
WHERE qpq.quality_profile_id IN (${idPlaceholders})
|
||||
ORDER BY qpq.quality_profile_id, qpq.position
|
||||
`, ...profileIds);
|
||||
|
||||
// 5. Get languages for all profiles (one per profile)
|
||||
const allLanguages = cache.query<{
|
||||
quality_profile_id: number;
|
||||
language_id: number;
|
||||
language_name: string;
|
||||
type: string;
|
||||
}>(`
|
||||
SELECT
|
||||
qpl.quality_profile_id,
|
||||
l.id as language_id,
|
||||
l.name as language_name,
|
||||
qpl.type
|
||||
FROM quality_profile_languages qpl
|
||||
JOIN languages l ON qpl.language_id = l.id
|
||||
WHERE qpl.quality_profile_id IN (${idPlaceholders})
|
||||
`, ...profileIds);
|
||||
|
||||
// Build maps for efficient lookup
|
||||
const tagsMap = new Map<number, Tag[]>();
|
||||
for (const tag of allTags) {
|
||||
if (!tagsMap.has(tag.quality_profile_id)) {
|
||||
tagsMap.set(tag.quality_profile_id, []);
|
||||
}
|
||||
tagsMap.get(tag.quality_profile_id)!.push({
|
||||
id: tag.tag_id,
|
||||
name: tag.tag_name,
|
||||
created_at: tag.tag_created_at
|
||||
});
|
||||
}
|
||||
|
||||
const formatCountsMap = new Map<number, Omit<CustomFormatCounts, 'total'>>();
|
||||
for (const fc of formatCounts) {
|
||||
if (!formatCountsMap.has(fc.quality_profile_id)) {
|
||||
formatCountsMap.set(fc.quality_profile_id, {all: 0, radarr: 0, sonarr: 0});
|
||||
}
|
||||
const counts = formatCountsMap.get(fc.quality_profile_id)!;
|
||||
if (fc.arr_type === 'all') counts.all = fc.count;
|
||||
else if (fc.arr_type === 'radarr') counts.radarr = fc.count;
|
||||
else if (fc.arr_type === 'sonarr') counts.sonarr = fc.count;
|
||||
}
|
||||
|
||||
const qualitiesMap = new Map<number, QualityItem[]>();
|
||||
for (const qual of allQualities) {
|
||||
if (!qualitiesMap.has(qual.quality_profile_id)) {
|
||||
qualitiesMap.set(qual.quality_profile_id, []);
|
||||
}
|
||||
|
||||
qualitiesMap.get(qual.quality_profile_id)!.push({
|
||||
position: qual.position,
|
||||
type: qual.quality_id ? 'quality' : 'group',
|
||||
id: qual.quality_id || qual.quality_group_id!,
|
||||
name: qual.quality_name || qual.group_name!,
|
||||
is_upgrade_until: qual.upgrade_until === 1
|
||||
});
|
||||
}
|
||||
|
||||
const languagesMap = new Map<number, ProfileLanguage>();
|
||||
for (const lang of allLanguages) {
|
||||
languagesMap.set(lang.quality_profile_id, {
|
||||
id: lang.language_id,
|
||||
name: lang.language_name,
|
||||
type: lang.type as 'must' | 'only' | 'not' | 'simple'
|
||||
});
|
||||
}
|
||||
|
||||
// Build the final result
|
||||
return profiles.map(profile => {
|
||||
const counts = formatCountsMap.get(profile.id) || {all: 0, radarr: 0, sonarr: 0};
|
||||
|
||||
const result: QualityProfileTableRow = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
description: parseMarkdown(profile.description),
|
||||
tags: tagsMap.get(profile.id) || [],
|
||||
upgrades_allowed: profile.upgrades_allowed === 1,
|
||||
minimum_custom_format_score: profile.minimum_custom_format_score,
|
||||
custom_formats: {
|
||||
all: counts.all,
|
||||
radarr: counts.radarr,
|
||||
sonarr: counts.sonarr,
|
||||
total: counts.all + counts.radarr + counts.sonarr
|
||||
},
|
||||
qualities: qualitiesMap.get(profile.id) || [],
|
||||
language: languagesMap.get(profile.id)
|
||||
};
|
||||
|
||||
// Only include upgrade settings if upgrades are allowed
|
||||
if (profile.upgrades_allowed === 1) {
|
||||
result.upgrade_until_score = profile.upgrade_until_score;
|
||||
result.upgrade_score_increment = profile.upgrade_score_increment;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
21
src/routes/api/databases/+server.ts
Normal file
21
src/routes/api/databases/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
|
||||
/**
|
||||
* GET /api/databases
|
||||
* Returns all linked database instances
|
||||
*/
|
||||
export const GET: RequestHandler = () => {
|
||||
try {
|
||||
const databases = pcdManager.getAll();
|
||||
return json(databases);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,29 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Plus, Pencil } from 'lucide-svelte';
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Build tabs from instances
|
||||
const tabs = data.allInstances.map((instance) => ({
|
||||
label: instance.name,
|
||||
href: `/arr/${data.type}/${instance.id}`,
|
||||
active: instance.id === data.instance.id
|
||||
}));
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Tabs Section -->
|
||||
<div class="mb-8">
|
||||
<div class="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<nav class="-mb-px flex gap-2" aria-label="Tabs">
|
||||
<!-- Instance Tabs -->
|
||||
{#each data.allInstances as instance (instance.id)}
|
||||
<a
|
||||
href="/arr/{data.type}/{instance.id}"
|
||||
class="border-b-2 px-4 py-3 text-sm font-medium transition-colors {data.instance.id ===
|
||||
instance.id
|
||||
? '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'}"
|
||||
>
|
||||
{instance.name}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Add Instance Tab (always visible) -->
|
||||
<Tabs {tabs}>
|
||||
<svelte:fragment slot="actions">
|
||||
<a
|
||||
href="/arr/new?type={data.type}"
|
||||
class="flex items-center gap-2 border-b-2 border-transparent px-4 py-3 text-sm font-medium text-neutral-600 transition-colors hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50"
|
||||
@@ -31,8 +25,8 @@
|
||||
<Plus size={16} />
|
||||
Add Instance
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
|
||||
18
src/routes/quality-profiles/+page.server.ts
Normal file
18
src/routes/quality-profiles/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
|
||||
export const load: ServerLoad = () => {
|
||||
// Get all databases
|
||||
const databases = pcdManager.getAll();
|
||||
|
||||
// If there are databases, redirect to the first one
|
||||
if (databases.length > 0) {
|
||||
throw redirect(303, `/quality-profiles/${databases[0].id}`);
|
||||
}
|
||||
|
||||
// If no databases, return empty array (page will show empty state)
|
||||
return {
|
||||
databases
|
||||
};
|
||||
};
|
||||
17
src/routes/quality-profiles/+page.svelte
Normal file
17
src/routes/quality-profiles/+page.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Database, Plus } from 'lucide-svelte';
|
||||
import EmptyState from '$ui/state/EmptyState.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quality Profiles - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No Databases Linked"
|
||||
description="Link a Profilarr Compliant Database to manage quality profiles."
|
||||
buttonText="Link Database"
|
||||
buttonHref="/databases/new"
|
||||
buttonIcon={Plus}
|
||||
/>
|
||||
39
src/routes/quality-profiles/[databaseId]/+page.server.ts
Normal file
39
src/routes/quality-profiles/[databaseId]/+page.server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles.ts';
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const { databaseId } = params;
|
||||
|
||||
// Get all databases for tabs
|
||||
const databases = pcdManager.getAll();
|
||||
|
||||
// Parse and validate the database ID
|
||||
const currentDatabaseId = parseInt(databaseId, 10);
|
||||
if (isNaN(currentDatabaseId)) {
|
||||
throw error(400, 'Invalid database ID');
|
||||
}
|
||||
|
||||
// Get the current database instance
|
||||
const currentDatabase = databases.find((db) => db.id === currentDatabaseId);
|
||||
|
||||
if (!currentDatabase) {
|
||||
throw error(404, 'Database not found');
|
||||
}
|
||||
|
||||
// Get the cache for the database
|
||||
const cache = pcdManager.getCache(currentDatabaseId);
|
||||
if (!cache) {
|
||||
throw error(500, 'Database cache not available');
|
||||
}
|
||||
|
||||
// Load quality profiles for the current database
|
||||
const qualityProfiles = qualityProfileQueries.list(cache);
|
||||
|
||||
return {
|
||||
databases,
|
||||
currentDatabase,
|
||||
qualityProfiles
|
||||
};
|
||||
};
|
||||
105
src/routes/quality-profiles/[databaseId]/+page.svelte
Normal file
105
src/routes/quality-profiles/[databaseId]/+page.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import Dropdown from '$ui/dropdown/Dropdown.svelte';
|
||||
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
|
||||
import TableView from './views/TableView.svelte';
|
||||
import CardView from './views/CardView.svelte';
|
||||
import { createSearchStore } from '$lib/client/stores/search';
|
||||
import { Eye, LayoutGrid, Table } from 'lucide-svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Initialize search store
|
||||
const searchStore = createSearchStore({ debounceMs: 300 });
|
||||
|
||||
// View state - load from localStorage or default to 'table'
|
||||
let currentView: 'cards' | 'table' = 'table';
|
||||
|
||||
if (browser) {
|
||||
const savedView = localStorage.getItem('qualityProfilesView') as 'cards' | 'table' | null;
|
||||
if (savedView) {
|
||||
currentView = savedView;
|
||||
}
|
||||
}
|
||||
|
||||
// Save view to localStorage when it changes
|
||||
$: if (browser && currentView) {
|
||||
localStorage.setItem('qualityProfilesView', currentView);
|
||||
}
|
||||
|
||||
// Map databases to tabs
|
||||
$: tabs = data.databases.map((db) => ({
|
||||
label: db.name,
|
||||
href: `/quality-profiles/${db.id}`,
|
||||
active: db.id === data.currentDatabase.id
|
||||
}));
|
||||
|
||||
// Filter quality profiles based on search
|
||||
$: filteredProfiles = data.qualityProfiles.filter((profile) => {
|
||||
const query = $searchStore.query;
|
||||
if (!query) return true;
|
||||
|
||||
const searchLower = query.toLowerCase();
|
||||
return profile.name?.toLowerCase().includes(searchLower);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quality Profiles - {data.currentDatabase.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 p-8">
|
||||
<!-- Tabs -->
|
||||
<Tabs {tabs} />
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search quality profiles..." />
|
||||
<ActionButton icon={Eye} hasDropdown={true} dropdownPosition="right">
|
||||
<Dropdown slot="dropdown" let:open position="right">
|
||||
<DropdownItem
|
||||
icon={LayoutGrid}
|
||||
label="Cards"
|
||||
selected={currentView === 'cards'}
|
||||
on:click={() => (currentView = 'cards')}
|
||||
/>
|
||||
<DropdownItem
|
||||
icon={Table}
|
||||
label="Table"
|
||||
selected={currentView === 'table'}
|
||||
on:click={() => (currentView = 'table')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</ActionButton>
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Quality Profiles Content -->
|
||||
<div class="mt-6">
|
||||
{#if data.qualityProfiles.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
No quality profiles found for {data.currentDatabase.name}
|
||||
</p>
|
||||
</div>
|
||||
{:else if filteredProfiles.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
No quality profiles match your search
|
||||
</p>
|
||||
</div>
|
||||
{:else if currentView === 'table'}
|
||||
<TableView profiles={filteredProfiles} />
|
||||
{:else}
|
||||
<CardView profiles={filteredProfiles} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
118
src/routes/quality-profiles/[databaseId]/views/CardView.svelte
Normal file
118
src/routes/quality-profiles/[databaseId]/views/CardView.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { QualityProfileTableRow } from '$lib/server/pcd/queries/qualityProfiles';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { BookOpenText, Gauge, Earth } from 'lucide-svelte';
|
||||
|
||||
export let profiles: QualityProfileTableRow[];
|
||||
|
||||
function handleCardClick(profile: QualityProfileTableRow) {
|
||||
const databaseId = $page.params.databaseId;
|
||||
goto(`/quality-profiles/${databaseId}/${profile.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each profiles as profile}
|
||||
<button
|
||||
on:click={() => handleCardClick(profile)}
|
||||
class="group relative flex flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-4 text-left transition-all hover:border-neutral-300 hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700 cursor-pointer"
|
||||
>
|
||||
<!-- Header with name and tags -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{profile.name}</h3>
|
||||
{#if profile.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each profile.tags as tag}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded font-mono text-[10px] bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description if exists -->
|
||||
{#if profile.description}
|
||||
<div class="description text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{@html profile.description}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Qualities preview -->
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{#each profile.qualities.slice(0, 3) as quality, idx}
|
||||
{#if idx > 0}
|
||||
<span class="text-xs text-neutral-400">›</span>
|
||||
{/if}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-mono text-neutral-900 dark:text-neutral-100
|
||||
{quality.is_upgrade_until
|
||||
? 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950'
|
||||
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}">
|
||||
{quality.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if profile.qualities.length > 3}
|
||||
<span class="text-[10px] text-neutral-500 dark:text-neutral-400">
|
||||
+{profile.qualities.length - 3} more
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex items-center gap-3 border-t border-neutral-100 pt-3 text-xs dark:border-neutral-800">
|
||||
<!-- Custom Formats -->
|
||||
<div class="flex items-center gap-1">
|
||||
<BookOpenText size={12} class="text-neutral-400" />
|
||||
<span class="font-mono text-[10px] text-neutral-900 dark:text-neutral-100 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">
|
||||
{profile.custom_formats.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Scores -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Gauge size={12} class="text-neutral-400" />
|
||||
<span class="font-mono text-[10px] text-neutral-900 dark:text-neutral-100 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">
|
||||
{profile.minimum_custom_format_score}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
{#if profile.language}
|
||||
<div class="flex items-center gap-1">
|
||||
<Earth size={12} class="text-neutral-400" />
|
||||
<span class="font-mono text-[10px] text-neutral-900 dark:text-neutral-100 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">
|
||||
{profile.language.name}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.description :global(ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.description :global(ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.description :global(li) {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.description :global(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.description :global(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
145
src/routes/quality-profiles/[databaseId]/views/TableView.svelte
Normal file
145
src/routes/quality-profiles/[databaseId]/views/TableView.svelte
Normal file
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import Table, { type Column } from '$ui/table/Table.svelte';
|
||||
import type { QualityProfileTableRow } from '$lib/server/pcd/queries/qualityProfiles';
|
||||
import { Tag, FileText, Layers, BookOpenText, Gauge, Earth } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let profiles: QualityProfileTableRow[];
|
||||
|
||||
function handleRowClick(row: QualityProfileTableRow) {
|
||||
// Get the current database ID from the URL
|
||||
const databaseId = $page.params.databaseId;
|
||||
goto(`/quality-profiles/${databaseId}/${row.id}`);
|
||||
}
|
||||
|
||||
// Define table columns for quality profiles
|
||||
const columns: Column<QualityProfileTableRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
headerIcon: Tag,
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
cell: (row: QualityProfileTableRow) => ({
|
||||
html: `
|
||||
<div>
|
||||
<div class="font-medium">${row.name}</div>
|
||||
${row.tags.length > 0 ? `
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
${row.tags.map(tag => `
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded font-mono text-[10px] bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
${tag.name}
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Description',
|
||||
headerIcon: FileText,
|
||||
align: 'left',
|
||||
cell: (row: QualityProfileTableRow) => ({ html: row.description || '<span class="text-neutral-400">No description</span>' })
|
||||
},
|
||||
{
|
||||
key: 'qualities',
|
||||
header: 'Qualities',
|
||||
headerIcon: Layers,
|
||||
align: 'left',
|
||||
width: 'w-48',
|
||||
cell: (row: QualityProfileTableRow) => {
|
||||
return {
|
||||
html: `
|
||||
<div class="space-y-1 py-2">
|
||||
${row.qualities.map(q => `
|
||||
<div class="relative px-2 py-0.5 rounded border ${
|
||||
q.is_upgrade_until
|
||||
? 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950'
|
||||
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'
|
||||
}">
|
||||
<span class="font-mono text-xs">${q.name}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'custom_formats',
|
||||
header: 'Custom Formats',
|
||||
headerIcon: BookOpenText,
|
||||
align: 'left',
|
||||
width: 'w-48',
|
||||
cell: (row: QualityProfileTableRow) => ({
|
||||
html: `
|
||||
<div class="text-xs space-y-0.5">
|
||||
<div>All: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.custom_formats.all}</span></div>
|
||||
<div>Radarr: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.custom_formats.radarr}</span></div>
|
||||
<div>Sonarr: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.custom_formats.sonarr}</span></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'scores',
|
||||
header: 'Scores',
|
||||
headerIcon: Gauge,
|
||||
align: 'left',
|
||||
width: 'w-52',
|
||||
cell: (row: QualityProfileTableRow) => ({
|
||||
html: `
|
||||
<div class="text-xs space-y-0.5">
|
||||
<div>Minimum: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.minimum_custom_format_score}</span></div>
|
||||
${row.upgrades_allowed ? `
|
||||
<div>Upgrade Until: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.upgrade_until_score}</span></div>
|
||||
<div>Increment: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.upgrade_score_increment}</span></div>
|
||||
` : `
|
||||
<div class="text-neutral-500 dark:text-neutral-400">No Upgrades</div>
|
||||
`}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'language',
|
||||
header: 'Language',
|
||||
headerIcon: Earth,
|
||||
align: 'left',
|
||||
width: 'w-40',
|
||||
cell: (row: QualityProfileTableRow) => {
|
||||
if (!row.language) return { html: '<span class="text-neutral-400">-</span>' };
|
||||
|
||||
const typePrefix = {
|
||||
must: 'Must Include',
|
||||
only: 'Must Only Be',
|
||||
not: 'Does Not Include',
|
||||
simple: ''
|
||||
};
|
||||
|
||||
if (row.language.type === 'simple') {
|
||||
return {
|
||||
html: `<div class="text-xs"><span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.language.name}</span></div>`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<div class="text-xs">${typePrefix[row.language.type]} <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.language.name}</span></div>`
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Table
|
||||
data={profiles}
|
||||
{columns}
|
||||
emptyMessage="No quality profiles found"
|
||||
hoverable={true}
|
||||
compact={false}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
Reference in New Issue
Block a user