mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat(custom-formats): implement custom formats management with list, detail views, and search functionality
This commit is contained in:
31
src/lib/client/ui/badge/Badge.svelte
Normal file
31
src/lib/client/ui/badge/Badge.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export let variant: 'accent' | 'neutral' | 'success' | 'warning' | 'danger' = 'accent';
|
||||
export let size: 'sm' | 'md' = 'sm';
|
||||
export let icon: ComponentType | null = null;
|
||||
|
||||
const variantClasses: Record<typeof variant, string> = {
|
||||
accent: 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200',
|
||||
neutral: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||
success: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
|
||||
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
danger: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
};
|
||||
|
||||
const sizeClasses: Record<typeof size, string> = {
|
||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||
md: 'px-2 py-0.5 text-xs'
|
||||
};
|
||||
|
||||
$: iconSize = size === 'sm' ? 10 : 12;
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded font-medium {variantClasses[variant]} {sizeClasses[size]}"
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component this={icon} size={iconSize} />
|
||||
{/if}
|
||||
<slot />
|
||||
</span>
|
||||
9
src/lib/server/pcd/queries/customFormats/index.ts
Normal file
9
src/lib/server/pcd/queries/customFormats/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Custom Format queries and mutations
|
||||
*/
|
||||
|
||||
// Export all types
|
||||
export type { CustomFormatTableRow, ConditionRef } from './types.ts';
|
||||
|
||||
// Export query functions
|
||||
export { list } from './list.ts';
|
||||
85
src/lib/server/pcd/queries/customFormats/list.ts
Normal file
85
src/lib/server/pcd/queries/customFormats/list.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Custom format list queries
|
||||
*/
|
||||
|
||||
import type { PCDCache } from '../../cache.ts';
|
||||
import type { Tag } from '../../types.ts';
|
||||
import type { CustomFormatTableRow, ConditionRef } from './types.ts';
|
||||
|
||||
/**
|
||||
* Get custom formats with full data for table/card views
|
||||
*/
|
||||
export async function list(cache: PCDCache): Promise<CustomFormatTableRow[]> {
|
||||
const db = cache.kb;
|
||||
|
||||
// 1. Get all custom formats
|
||||
const formats = await db
|
||||
.selectFrom('custom_formats')
|
||||
.select(['id', 'name', 'description'])
|
||||
.orderBy('name')
|
||||
.execute();
|
||||
|
||||
if (formats.length === 0) return [];
|
||||
|
||||
const formatIds = formats.map((f) => f.id);
|
||||
|
||||
// 2. Get all tags for all custom formats
|
||||
const allTags = await db
|
||||
.selectFrom('custom_format_tags as cft')
|
||||
.innerJoin('tags as t', 't.id', 'cft.tag_id')
|
||||
.select([
|
||||
'cft.custom_format_id',
|
||||
't.id as tag_id',
|
||||
't.name as tag_name',
|
||||
't.created_at as tag_created_at'
|
||||
])
|
||||
.where('cft.custom_format_id', 'in', formatIds)
|
||||
.orderBy('cft.custom_format_id')
|
||||
.orderBy('t.name')
|
||||
.execute();
|
||||
|
||||
// 3. Get all conditions for all custom formats
|
||||
const allConditions = await db
|
||||
.selectFrom('custom_format_conditions')
|
||||
.select(['id', 'custom_format_id', 'name', 'required', 'negate'])
|
||||
.where('custom_format_id', 'in', formatIds)
|
||||
.orderBy('custom_format_id')
|
||||
.orderBy('name')
|
||||
.execute();
|
||||
|
||||
// Build tags map
|
||||
const tagsMap = new Map<number, Tag[]>();
|
||||
for (const tag of allTags) {
|
||||
if (!tagsMap.has(tag.custom_format_id)) {
|
||||
tagsMap.set(tag.custom_format_id, []);
|
||||
}
|
||||
tagsMap.get(tag.custom_format_id)!.push({
|
||||
id: tag.tag_id,
|
||||
name: tag.tag_name,
|
||||
created_at: tag.tag_created_at
|
||||
});
|
||||
}
|
||||
|
||||
// Build conditions map
|
||||
const conditionsMap = new Map<number, ConditionRef[]>();
|
||||
for (const condition of allConditions) {
|
||||
if (!conditionsMap.has(condition.custom_format_id)) {
|
||||
conditionsMap.set(condition.custom_format_id, []);
|
||||
}
|
||||
conditionsMap.get(condition.custom_format_id)!.push({
|
||||
id: condition.id,
|
||||
name: condition.name,
|
||||
required: condition.required === 1,
|
||||
negate: condition.negate === 1
|
||||
});
|
||||
}
|
||||
|
||||
// Build the final result
|
||||
return formats.map((format) => ({
|
||||
id: format.id,
|
||||
name: format.name,
|
||||
description: format.description,
|
||||
tags: tagsMap.get(format.id) || [],
|
||||
conditions: conditionsMap.get(format.id) || []
|
||||
}));
|
||||
}
|
||||
22
src/lib/server/pcd/queries/customFormats/types.ts
Normal file
22
src/lib/server/pcd/queries/customFormats/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Custom Format query-specific types
|
||||
*/
|
||||
|
||||
import type { Tag } from '../../types.ts';
|
||||
|
||||
/** Condition reference for display */
|
||||
export interface ConditionRef {
|
||||
id: number;
|
||||
name: string;
|
||||
required: boolean;
|
||||
negate: boolean;
|
||||
}
|
||||
|
||||
/** Custom format data for table/card views */
|
||||
export interface CustomFormatTableRow {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tags: Tag[];
|
||||
conditions: ConditionRef[];
|
||||
}
|
||||
18
src/routes/custom-formats/+page.server.ts
Normal file
18
src/routes/custom-formats/+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, `/custom-formats/${databases[0].id}`);
|
||||
}
|
||||
|
||||
// If no databases, return empty array (page will show empty state)
|
||||
return {
|
||||
databases
|
||||
};
|
||||
};
|
||||
17
src/routes/custom-formats/+page.svelte
Normal file
17
src/routes/custom-formats/+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>Custom Formats - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No Databases Linked"
|
||||
description="Link a Profilarr Compliant Database to manage custom formats."
|
||||
buttonText="Link Database"
|
||||
buttonHref="/databases/new"
|
||||
buttonIcon={Plus}
|
||||
/>
|
||||
44
src/routes/custom-formats/[databaseId]/+page.server.ts
Normal file
44
src/routes/custom-formats/[databaseId]/+page.server.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const { databaseId } = params;
|
||||
|
||||
// Validate params exist
|
||||
if (!databaseId) {
|
||||
throw error(400, 'Missing database ID');
|
||||
}
|
||||
|
||||
// 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 custom formats for the current database
|
||||
const customFormats = await customFormatQueries.list(cache);
|
||||
|
||||
return {
|
||||
databases,
|
||||
currentDatabase,
|
||||
customFormats
|
||||
};
|
||||
};
|
||||
181
src/routes/custom-formats/[databaseId]/+page.svelte
Normal file
181
src/routes/custom-formats/[databaseId]/+page.svelte
Normal file
@@ -0,0 +1,181 @@
|
||||
<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 ViewToggle from '$ui/actions/ViewToggle.svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import TableView from './views/TableView.svelte';
|
||||
import CardView from './views/CardView.svelte';
|
||||
import SearchFilterAction from './components/SearchFilterAction.svelte';
|
||||
import { createDataPageStore } from '$lib/client/stores/dataPage';
|
||||
import { browser } from '$app/environment';
|
||||
import { Info, Plus } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { CustomFormatTableRow } from '$pcd/queries/customFormats';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let infoModalOpen = false;
|
||||
|
||||
const SEARCH_FILTER_STORAGE_KEY = 'customFormatsSearchFilter';
|
||||
|
||||
// Default search filter options
|
||||
const defaultSearchOptions = [
|
||||
{ key: 'name', label: 'Name', enabled: true },
|
||||
{ key: 'tags', label: 'Tags', enabled: true },
|
||||
{ key: 'description', label: 'Description', enabled: false }
|
||||
];
|
||||
|
||||
// Load saved preferences from localStorage or use defaults
|
||||
function loadSearchOptions() {
|
||||
if (!browser) return defaultSearchOptions;
|
||||
try {
|
||||
const saved = localStorage.getItem(SEARCH_FILTER_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const savedMap = new Map(JSON.parse(saved) as [string, boolean][]);
|
||||
return defaultSearchOptions.map((opt) => ({
|
||||
...opt,
|
||||
enabled: savedMap.has(opt.key) ? savedMap.get(opt.key)! : opt.enabled
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors, use defaults
|
||||
}
|
||||
return defaultSearchOptions;
|
||||
}
|
||||
|
||||
let searchOptions = loadSearchOptions();
|
||||
|
||||
// Save to localStorage when options change
|
||||
$: if (browser) {
|
||||
const enabledMap = searchOptions.map((opt) => [opt.key, opt.enabled] as [string, boolean]);
|
||||
localStorage.setItem(SEARCH_FILTER_STORAGE_KEY, JSON.stringify(enabledMap));
|
||||
}
|
||||
|
||||
// Initialize data page store (we'll use search and view, but do our own filtering)
|
||||
const { search, view, setItems } = createDataPageStore(data.customFormats, {
|
||||
storageKey: 'customFormatsView',
|
||||
searchKeys: ['name'] // Placeholder, we do our own filtering
|
||||
});
|
||||
|
||||
// Extract the debounced query store for reactive access
|
||||
const debouncedQuery = search.debouncedQuery;
|
||||
|
||||
// Update items when data changes (e.g., switching databases)
|
||||
$: setItems(data.customFormats);
|
||||
|
||||
// Custom filtering based on selected search options
|
||||
$: filtered = filterFormats(data.customFormats, $debouncedQuery, searchOptions);
|
||||
|
||||
function filterFormats(
|
||||
items: CustomFormatTableRow[],
|
||||
query: string,
|
||||
options: typeof searchOptions
|
||||
): CustomFormatTableRow[] {
|
||||
if (!query) return items;
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const enabledKeys = options.filter((o) => o.enabled).map((o) => o.key);
|
||||
|
||||
return items.filter((item) => {
|
||||
return enabledKeys.some((key) => {
|
||||
if (key === 'tags') {
|
||||
// Search within tag names
|
||||
return item.tags.some((tag) => tag.name.toLowerCase().includes(queryLower));
|
||||
}
|
||||
const value = item[key as keyof CustomFormatTableRow];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(queryLower);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Map databases to tabs
|
||||
$: tabs = data.databases.map((db) => ({
|
||||
label: db.name,
|
||||
href: `/custom-formats/${db.id}`,
|
||||
active: db.id === data.currentDatabase.id
|
||||
}));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Custom Formats - {data.currentDatabase.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 p-8">
|
||||
<!-- Tabs -->
|
||||
<Tabs {tabs} />
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction searchStore={search} placeholder="Search custom formats..." />
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
on:click={() => goto(`/custom-formats/${data.currentDatabase.id}/new`)}
|
||||
/>
|
||||
<SearchFilterAction bind:options={searchOptions} />
|
||||
<ViewToggle bind:value={$view} />
|
||||
<ActionButton icon={Info} on:click={() => (infoModalOpen = true)} />
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Custom Formats Content -->
|
||||
<div class="mt-6">
|
||||
{#if data.customFormats.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 custom formats found for {data.currentDatabase.name}
|
||||
</p>
|
||||
</div>
|
||||
{:else if filtered.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 custom formats match your search
|
||||
</p>
|
||||
</div>
|
||||
{:else if $view === 'table'}
|
||||
<TableView formats={filtered} />
|
||||
{:else}
|
||||
<CardView formats={filtered} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Modal -->
|
||||
<InfoModal bind:open={infoModalOpen} header="About Custom Formats">
|
||||
<div class="space-y-4 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<section>
|
||||
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">What Are Custom Formats?</h3>
|
||||
<p>
|
||||
Custom formats are rules that match specific release characteristics like codec, resolution,
|
||||
source, or release group. They're used to score releases and guide quality decisions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">How They Work</h3>
|
||||
<p>
|
||||
Each custom format contains one or more conditions. A release must match all required
|
||||
conditions (or at least one non-required condition) to be assigned the custom format.
|
||||
Quality profiles then assign scores to determine which releases are preferred.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">Condition Types</h3>
|
||||
<ul class="list-inside list-disc space-y-1">
|
||||
<li><strong>Release Title</strong> - Match patterns in the release name</li>
|
||||
<li><strong>Release Group</strong> - Match specific release groups</li>
|
||||
<li><strong>Edition</strong> - Match edition names (Director's Cut, etc.)</li>
|
||||
<li><strong>Language</strong> - Match audio language</li>
|
||||
<li><strong>Source</strong> - Match release source (BluRay, WEB, etc.)</li>
|
||||
<li><strong>Resolution</strong> - Match video resolution</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</InfoModal>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { Binoculars, Check } from 'lucide-svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let options: { key: string; label: string; enabled: boolean }[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher<{ change: { key: string; enabled: boolean }[] }>();
|
||||
|
||||
let isHovered = false;
|
||||
let leaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (leaveTimer) {
|
||||
clearTimeout(leaveTimer);
|
||||
leaveTimer = null;
|
||||
}
|
||||
isHovered = true;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
leaveTimer = setTimeout(() => {
|
||||
isHovered = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function toggleOption(key: string) {
|
||||
options = options.map((opt) =>
|
||||
opt.key === key ? { ...opt, enabled: !opt.enabled } : opt
|
||||
);
|
||||
dispatch('change', options);
|
||||
}
|
||||
|
||||
$: enabledCount = options.filter((o) => o.enabled).length;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex"
|
||||
on:mouseenter={handleMouseEnter}
|
||||
on:mouseleave={handleMouseLeave}
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="flex h-10 w-10 items-center justify-center border border-neutral-200 bg-white transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<div class="relative">
|
||||
<Binoculars size={20} class="text-neutral-700 dark:text-neutral-300" />
|
||||
{#if enabledCount < options.length}
|
||||
<div
|
||||
class="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-accent-600 text-[9px] font-bold text-white"
|
||||
>
|
||||
{enabledCount}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if isHovered}
|
||||
<div class="z-50" transition:fly={{ y: -8, duration: 150 }}>
|
||||
<div class="absolute top-full z-40 h-3 w-full"></div>
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-3 min-w-48 rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="px-3 py-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Search in...
|
||||
</div>
|
||||
{#each options as option}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 border-t border-neutral-200 px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-700"
|
||||
on:click={() => toggleOption(option.key)}
|
||||
>
|
||||
<div
|
||||
class="flex h-4 w-4 items-center justify-center rounded border {option.enabled
|
||||
? 'border-accent-600 bg-accent-600 dark:border-accent-500 dark:bg-accent-500'
|
||||
: 'border-neutral-300 dark:border-neutral-600'}"
|
||||
>
|
||||
{#if option.enabled}
|
||||
<Check size={12} class="text-white" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
96
src/routes/custom-formats/[databaseId]/views/CardView.svelte
Normal file
96
src/routes/custom-formats/[databaseId]/views/CardView.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import type { CustomFormatTableRow } from '$pcd/queries/customFormats';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
import { marked } from 'marked';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let formats: CustomFormatTableRow[];
|
||||
|
||||
function handleCardClick(format: CustomFormatTableRow) {
|
||||
const databaseId = $page.params.databaseId;
|
||||
goto(`/custom-formats/${databaseId}/${format.id}`);
|
||||
}
|
||||
|
||||
// Configure marked for inline parsing (no wrapping <p> tags for short text)
|
||||
function parseMarkdown(text: string | null): string {
|
||||
if (!text) return '';
|
||||
return marked.parseInline(text) as string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each formats as format}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => handleCardClick(format)}
|
||||
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 condition count -->
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{format.name}
|
||||
</h3>
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center gap-1 rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
|
||||
title="{format.conditions.length} condition{format.conditions.length !== 1 ? 's' : ''}"
|
||||
>
|
||||
<Layers size={12} />
|
||||
<span>{format.conditions.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if format.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each format.tags as tag}
|
||||
<span
|
||||
class="inline-flex items-center rounded px-1.5 py-0.5 font-mono text-[10px] bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if format.description}
|
||||
<div class="text-xs text-neutral-600 dark:text-neutral-400 line-clamp-2 prose-inline">
|
||||
{@html parseMarkdown(format.description)}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-neutral-400 dark:text-neutral-500 italic">
|
||||
No description
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Inline prose styles for markdown content */
|
||||
:global(.prose-inline code) {
|
||||
background-color: rgb(229 231 235);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
:global(.dark .prose-inline code) {
|
||||
background-color: rgb(38 38 38);
|
||||
}
|
||||
|
||||
:global(.prose-inline strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.prose-inline a) {
|
||||
color: rgb(var(--color-accent-600));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:global(.dark .prose-inline a) {
|
||||
color: rgb(var(--color-accent-400));
|
||||
}
|
||||
</style>
|
||||
134
src/routes/custom-formats/[databaseId]/views/TableView.svelte
Normal file
134
src/routes/custom-formats/[databaseId]/views/TableView.svelte
Normal file
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import type { CustomFormatTableRow } from '$pcd/queries/customFormats';
|
||||
import { Tag, FileText, Layers } from 'lucide-svelte';
|
||||
import { marked } from 'marked';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let formats: CustomFormatTableRow[];
|
||||
|
||||
function handleRowClick(row: CustomFormatTableRow) {
|
||||
const databaseId = $page.params.databaseId;
|
||||
goto(`/custom-formats/${databaseId}/${row.id}`);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function parseMarkdown(text: string | null): string {
|
||||
if (!text) return '';
|
||||
return marked.parseInline(text) as string;
|
||||
}
|
||||
|
||||
const columns: Column<CustomFormatTableRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
headerIcon: Tag,
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: 'w-48',
|
||||
cell: (row: CustomFormatTableRow) => ({
|
||||
html: `
|
||||
<div>
|
||||
<div class="font-medium">${escapeHtml(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-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200">
|
||||
${escapeHtml(tag.name)}
|
||||
</span>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Description',
|
||||
headerIcon: FileText,
|
||||
align: 'left',
|
||||
cell: (row: CustomFormatTableRow) => ({
|
||||
html: row.description
|
||||
? `<span class="text-sm text-neutral-600 dark:text-neutral-400 prose-inline">${parseMarkdown(row.description)}</span>`
|
||||
: `<span class="text-neutral-400">-</span>`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'conditions',
|
||||
header: 'Conditions',
|
||||
headerIcon: Layers,
|
||||
align: 'left',
|
||||
cell: (row: CustomFormatTableRow) => ({
|
||||
html: row.conditions.length > 0
|
||||
? `<div class="flex flex-wrap gap-1">${row.conditions.map((c) => {
|
||||
// Color based on required/negate:
|
||||
// required + negate = red (must NOT match)
|
||||
// required + !negate = accent (must match)
|
||||
// !required + negate = amber (optional negative)
|
||||
// !required + !negate = neutral (optional)
|
||||
let colorClass: string;
|
||||
if (c.required && c.negate) {
|
||||
colorClass = 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
} else if (c.required) {
|
||||
colorClass = 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200';
|
||||
} else if (c.negate) {
|
||||
colorClass = 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
|
||||
} else {
|
||||
colorClass = 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300';
|
||||
}
|
||||
return `<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${colorClass}">${escapeHtml(c.name)}</span>`;
|
||||
}).join('')}</div>`
|
||||
: `<span class="text-neutral-400 text-xs">None</span>`
|
||||
})
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Table data={formats} {columns} emptyMessage="No custom formats found" hoverable={true} compact={false} onRowClick={handleRowClick} />
|
||||
|
||||
<style>
|
||||
/* Inline prose styles for markdown content */
|
||||
:global(.prose-inline code) {
|
||||
background-color: rgb(229 231 235);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
:global(.dark .prose-inline code) {
|
||||
background-color: rgb(38 38 38);
|
||||
}
|
||||
|
||||
:global(.prose-inline strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.prose-inline a) {
|
||||
color: rgb(var(--color-accent-600));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:global(.dark .prose-inline a) {
|
||||
color: rgb(var(--color-accent-400));
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user