feat: add database configuration page with manifest and README support

This commit is contained in:
Sam Chau
2026-01-17 00:06:08 +10:30
parent e104676f77
commit 4186e1413a
7 changed files with 801 additions and 35 deletions

View File

@@ -89,6 +89,255 @@
--color-accent-950: var(--accent-950);
}
/* Prose styles for rendered markdown */
.prose {
line-height: 1.75;
color: rgb(64 64 64);
}
.dark .prose {
color: rgb(212 212 212);
}
.prose > :first-child {
margin-top: 0;
}
.prose > :last-child {
margin-bottom: 0;
}
.prose h1 {
font-size: 1.875em;
font-weight: 700;
margin-top: 0;
margin-bottom: 0.875em;
color: rgb(23 23 23);
letter-spacing: -0.025em;
}
.dark .prose h1 {
color: rgb(250 250 250);
}
.prose h2 {
font-size: 1.375em;
font-weight: 600;
margin-top: 1.75em;
margin-bottom: 0.5em;
color: rgb(38 38 38);
letter-spacing: -0.015em;
}
.dark .prose h2 {
color: rgb(245 245 245);
}
.prose h3 {
font-size: 1.125em;
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
color: rgb(64 64 64);
}
.dark .prose h3 {
color: rgb(229 229 229);
}
.prose h4 {
font-size: 1em;
font-weight: 600;
margin-top: 1.25em;
margin-bottom: 0.5em;
color: rgb(64 64 64);
}
.dark .prose h4 {
color: rgb(229 229 229);
}
.prose p {
margin-top: 1em;
margin-bottom: 1em;
}
.prose ul,
.prose ol {
margin-top: 1em;
margin-bottom: 1em;
padding-left: 1.5em;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
margin-top: 0.375em;
margin-bottom: 0.375em;
}
.prose li > ul,
.prose li > ol {
margin-top: 0.375em;
margin-bottom: 0.375em;
}
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
background-color: rgb(245 245 245);
color: rgb(64 64 64);
padding: 0.2em 0.4em;
border-radius: 0.375rem;
font-weight: 500;
}
.dark .prose code {
background-color: transparent;
color: rgb(229 229 229);
}
.prose pre {
background-color: rgb(250 250 250);
border: 1px solid rgb(229 229 229);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-top: 1.25em;
margin-bottom: 1.25em;
}
.dark .prose pre {
background-color: rgb(23 23 23);
border-color: rgb(64 64 64);
}
.prose pre code {
background-color: transparent;
padding: 0;
font-size: 0.875em;
font-weight: 400;
color: inherit;
}
.prose a {
color: var(--color-accent-600);
text-decoration: none;
font-weight: 500;
transition: color 0.15s;
}
.prose a:hover {
color: var(--color-accent-700);
text-decoration: underline;
}
.dark .prose a {
color: var(--color-accent-400);
}
.dark .prose a:hover {
color: var(--color-accent-300);
}
.prose strong {
font-weight: 600;
color: rgb(38 38 38);
}
.dark .prose strong {
color: rgb(245 245 245);
}
.prose blockquote {
border-left: 3px solid var(--color-accent-400);
padding-left: 1em;
margin-top: 1.25em;
margin-bottom: 1.25em;
font-style: italic;
color: rgb(115 115 115);
}
.dark .prose blockquote {
border-left-color: var(--color-accent-600);
color: rgb(163 163 163);
}
.prose blockquote p {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose hr {
border: none;
border-top: 1px solid rgb(229 229 229);
margin-top: 2em;
margin-bottom: 2em;
}
.dark .prose hr {
border-top-color: rgb(64 64 64);
}
.prose table {
width: 100%;
border-collapse: collapse;
margin-top: 1.25em;
margin-bottom: 1.25em;
font-size: 0.875em;
}
.prose th,
.prose td {
border: 1px solid rgb(229 229 229);
padding: 0.625rem 0.875rem;
text-align: left;
}
.dark .prose th,
.dark .prose td {
border-color: rgb(64 64 64);
}
.prose th {
background-color: rgb(250 250 250);
font-weight: 600;
color: rgb(38 38 38);
}
.dark .prose th {
background-color: rgb(38 38 38);
color: rgb(245 245 245);
}
.prose img {
border-radius: 0.5rem;
margin-top: 1.25em;
margin-bottom: 1.25em;
}
.prose-sm {
font-size: 0.875rem;
}
.prose-sm h1 {
font-size: 1.5em;
}
.prose-sm h2 {
font-size: 1.25em;
}
.prose-sm h3 {
font-size: 1.125em;
}
@layer base {
* {
font-family: 'DM Sans', ui-sans-serif, system-ui, sans-serif;

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import { Plus, Trash2 } from 'lucide-svelte';
import NumberInput from './NumberInput.svelte';
export let value: Record<string, string> = {};
export let label: string = '';
export let description: string = '';
export let keyLabel: string = 'Key';
export let valueLabel: string = 'Value';
export let keyPlaceholder: string = 'Enter key';
export let valuePlaceholder: string = 'Enter value';
export let onchange: ((value: Record<string, string>) => void) | undefined = undefined;
export let lockedFirst: { key: string; value?: string; minMajor?: number } | undefined = undefined;
export let onLockedDeleteAttempt: (() => void) | undefined = undefined;
export let onLockedEditAttempt: (() => void) | undefined = undefined;
export let onLockedVersionMinBlocked: (() => void) | undefined = undefined;
export let valueType: 'text' | 'version' = 'text';
export let versionMinMajor: number = 0;
export let addDisabled: boolean = false;
export let onAddBlocked: (() => void) | undefined = undefined;
function parseVersion(v: string): [number, number, number] {
const parts = v.split('.').map((p) => parseInt(p, 10) || 0);
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function updateVersionPart(current: string, part: 0 | 1 | 2, val: number): string {
const parts = parseVersion(current);
parts[part] = val;
return parts.join('.');
}
// Convert object to array for easier manipulation
function initEntries() {
const arr = Object.entries(value).map(([k, v]) => ({ key: k, value: v }));
// Ensure locked first entry exists at index 0
if (lockedFirst) {
const existingIndex = arr.findIndex((e) => e.key === lockedFirst.key);
if (existingIndex === -1) {
arr.unshift({ key: lockedFirst.key, value: lockedFirst.value ?? '' });
} else if (existingIndex !== 0) {
const [item] = arr.splice(existingIndex, 1);
arr.unshift(item);
}
}
return arr;
}
let entries: Array<{ key: string; value: string }> = initEntries();
function syncToValue() {
const newValue: Record<string, string> = {};
for (const entry of entries) {
if (entry.key.trim()) {
newValue[entry.key.trim()] = entry.value;
}
}
value = newValue;
onchange?.(value);
}
function addEntry() {
if (addDisabled) {
onAddBlocked?.();
return;
}
entries = [...entries, { key: '', value: valueType === 'version' ? '1.0.0' : '' }];
}
function removeEntry(index: number) {
if (lockedFirst && index === 0) return;
entries = entries.filter((_, i) => i !== index);
syncToValue();
}
function updateKey(index: number, newKey: string) {
if (lockedFirst && index === 0) return;
entries[index].key = newKey;
entries = entries;
syncToValue();
}
function updateValue(index: number, newValue: string) {
entries[index].value = newValue;
entries = entries;
syncToValue();
}
// Sync when value changes externally (but preserve entries with empty keys being edited)
$: {
const externalEntries = Object.entries(value);
const currentFilledKeys = entries.filter((e) => e.key.trim()).map((e) => e.key).sort().join(',');
const externalKeys = externalEntries.map(([k]) => k).sort().join(',');
if (currentFilledKeys !== externalKeys) {
const emptyKeyEntries = entries.filter((e) => !e.key.trim());
entries = [...externalEntries.map(([k, v]) => ({ key: k, value: v })), ...emptyKeyEntries];
// Re-run initEntries logic to ensure locked first is at index 0
if (lockedFirst) {
const existingIndex = entries.findIndex((e) => e.key === lockedFirst.key);
if (existingIndex > 0) {
const [item] = entries.splice(existingIndex, 1);
entries.unshift(item);
}
}
}
}
</script>
<div class="space-y-3">
{#if label}
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
{label}
</label>
{/if}
{#if description}
<p class="text-xs text-neutral-500 dark:text-neutral-400">
{description}
</p>
{/if}
<div class="space-y-2">
{#if entries.length > 0}
<!-- Header -->
<div class="grid grid-cols-[1fr_auto_auto] gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
<span>{keyLabel}</span>
<span>{valueLabel}</span>
<span class="w-8"></span>
</div>
{/if}
<!-- Entries -->
{#each entries as entry, index (index)}
{@const isLocked = lockedFirst && index === 0}
{@const [vMajor, vMinor, vPatch] = parseVersion(entry.value)}
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
<input
type="text"
value={entry.key}
placeholder={keyPlaceholder}
oninput={(e) => updateKey(index, (e.target as HTMLInputElement).value)}
onfocus={(e) => {
if (isLocked) {
onLockedEditAttempt?.();
(e.target as HTMLInputElement).blur();
}
}}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:focus:border-neutral-500"
/>
{#if valueType === 'version'}
<div class="flex items-center gap-1">
<div class="w-16">
<NumberInput
name="version-major-{index}"
value={vMajor}
min={isLocked && lockedFirst?.minMajor !== undefined ? lockedFirst.minMajor : versionMinMajor}
font="mono"
onchange={(v) => updateValue(index, updateVersionPart(entry.value, 0, v))}
onMinBlocked={isLocked ? onLockedVersionMinBlocked : undefined}
/>
</div>
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
<div class="w-16">
<NumberInput
name="version-minor-{index}"
value={vMinor}
min={0}
font="mono"
onchange={(v) => updateValue(index, updateVersionPart(entry.value, 1, v))}
/>
</div>
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
<div class="w-16">
<NumberInput
name="version-patch-{index}"
value={vPatch}
min={0}
font="mono"
onchange={(v) => updateValue(index, updateVersionPart(entry.value, 2, v))}
/>
</div>
</div>
{:else}
<input
type="text"
value={entry.value}
placeholder={valuePlaceholder}
oninput={(e) => updateValue(index, (e.target as HTMLInputElement).value)}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:focus:border-neutral-500"
/>
{/if}
<button
type="button"
onclick={() => isLocked ? onLockedDeleteAttempt?.() : removeEntry(index)}
class="flex h-[38px] w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
aria-label="Remove entry"
>
<Trash2 size={16} />
</button>
</div>
{/each}
<!-- Add button -->
<button
type="button"
onclick={addEntry}
class="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 transition-colors hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
>
<Plus size={16} />
Add entry
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Bold, Italic, List, ListOrdered, Link, Code, Eye, Edit3 } from 'lucide-svelte';
import { marked } from 'marked';
import { onMount } from 'svelte';
// Props
@@ -140,35 +141,10 @@
}
}
// Simple markdown to HTML renderer for preview
// Markdown to HTML renderer for preview using marked
function renderMarkdown(text: string): string {
if (!text) return '<p class="text-neutral-400 dark:text-neutral-500 italic">Nothing to preview</p>';
let html = text
// Escape HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Inline code
.replace(/`(.+?)`/g, '<code class="px-1 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-sm font-mono">$1</code>')
// Links
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" class="text-accent-600 dark:text-accent-400 underline" target="_blank" rel="noopener">$1</a>')
// Escaped newlines (literal \n)
.replace(/\\n/g, '\n')
// Line breaks
.replace(/\n/g, '<br>');
// Unordered lists
html = html.replace(/(?:^|<br>)- (.+?)(?=<br>|$)/g, '<li class="ml-4 list-disc">$1</li>');
// Ordered lists
html = html.replace(/(?:^|<br>)\d+\. (.+?)(?=<br>|$)/g, '<li class="ml-4 list-decimal">$1</li>');
return html;
return marked.parse(text) as string;
}
const toolbarButtons = [
@@ -254,7 +230,7 @@
{#if showPreview && markdown}
<!-- Preview -->
<div
class="rounded-b-lg border border-neutral-300 bg-white px-3 py-2 text-sm leading-loose text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
class="prose prose-sm max-w-none rounded-b-lg border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800"
style="min-height: {minRows * 1.5}rem"
>
{@html renderMarkdown(value)}
@@ -272,7 +248,7 @@
{required}
oninput={handleInput}
onkeydown={handleKeydown}
class="{markdown ? 'rounded-b-lg rounded-t-none border-t-0' : 'rounded-lg'} {autoResize ? 'resize-none overflow-hidden' : ''} block w-full border border-neutral-300 bg-white px-3 py-2 text-sm leading-loose text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600"
class="{markdown ? 'rounded-b-lg rounded-t-none border-t-0' : 'rounded-lg'} {autoResize ? 'resize-none overflow-hidden' : ''} block w-full border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600"
></textarea>
{:else}
<!-- Single-line input -->

View File

@@ -12,6 +12,8 @@
export let disabled: boolean = false;
export let font: 'mono' | 'sans' | undefined = undefined;
export let onchange: ((value: number) => void) | undefined = undefined;
export let onMinBlocked: (() => void) | undefined = undefined;
export let onMaxBlocked: (() => void) | undefined = undefined;
$: fontClass = font === 'mono' ? 'font-mono' : font === 'sans' ? 'font-sans' : '';
@@ -22,12 +24,18 @@
// Increment/decrement handlers
function increment() {
if (max !== undefined && value >= max) return;
if (max !== undefined && value >= max) {
onMaxBlocked?.();
return;
}
updateValue(value + step);
}
function decrement() {
if (min !== undefined && value <= min) return;
if (min !== undefined && value <= min) {
onMinBlocked?.();
return;
}
updateValue(value - step);
}
@@ -72,7 +80,7 @@
<button
type="button"
on:click={increment}
disabled={disabled || (max !== undefined && value >= max)}
disabled={disabled}
class="flex h-4 w-6 items-center justify-center rounded-t border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
>
<ChevronUp size={12} />
@@ -80,7 +88,7 @@
<button
type="button"
on:click={decrement}
disabled={disabled || (min !== undefined && value <= min)}
disabled={disabled}
class="flex h-4 w-6 items-center justify-center rounded-b border border-t-0 border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
>
<ChevronDown size={12} />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { GitBranch, History, Wrench } from 'lucide-svelte';
import { GitBranch, History, Wrench, Settings } from 'lucide-svelte';
import { page } from '$app/stores';
$: database = $page.data.database;
@@ -28,7 +28,17 @@
href: `/databases/${database.id}/tweaks`,
icon: Wrench,
active: currentPath.includes('/tweaks')
}
},
...(database.personal_access_token
? [
{
label: 'Config',
href: `/databases/${database.id}/config`,
icon: Settings,
active: currentPath.includes('/config')
}
]
: [])
] : [];
$: backButton = {

View File

@@ -0,0 +1,35 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { readManifest, type Manifest } from '$lib/server/pcd/manifest.ts';
import { parseMarkdown } from '$utils/markdown/markdown.ts';
export const load: PageServerLoad = async ({ parent }) => {
const { database } = await parent();
if (!database.personal_access_token) {
error(403, 'Config page requires a personal access token');
}
let manifest: Manifest | null = null;
let readmeRaw: string | null = null;
let readmeHtml: string | null = null;
try {
manifest = await readManifest(database.local_path);
} catch {
// Manifest might not exist yet
}
try {
readmeRaw = await Deno.readTextFile(`${database.local_path}/README.md`);
readmeHtml = parseMarkdown(readmeRaw);
} catch {
// README might not exist
}
return {
manifest,
readmeRaw,
readmeHtml
};
};

View File

@@ -0,0 +1,275 @@
<script lang="ts">
import type { PageData } from './$types';
import NumberInput from '$ui/form/NumberInput.svelte';
import KeyValueList from '$ui/form/KeyValueList.svelte';
import TagInput from '$ui/form/TagInput.svelte';
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
import { alertStore } from '$lib/client/alerts/store';
export let data: PageData;
let manifest = data.manifest;
let readme = data.readmeRaw ?? '';
function update<K extends keyof NonNullable<typeof manifest>>(key: K, value: NonNullable<typeof manifest>[K]) {
if (!manifest) return;
manifest = { ...manifest, [key]: value };
}
function updateProfilarr(key: 'minimum_version', value: string) {
if (!manifest) return;
manifest = {
...manifest,
profilarr: { ...manifest.profilarr, [key]: value }
};
}
function parseVersion(v: string): [number, number, number] {
const parts = v.split('.').map((p) => parseInt(p, 10) || 0);
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function updateVersionPart(current: string, part: 0 | 1 | 2, value: number): string {
const parts = parseVersion(current);
parts[part] = value;
return parts.join('.');
}
$: [vMajor, vMinor, vPatch] = manifest ? parseVersion(manifest.version) : [0, 0, 0];
$: [pvMajor, pvMinor, pvPatch] = manifest ? parseVersion(manifest.profilarr.minimum_version) : [0, 0, 0];
</script>
<svelte:head>
<title>Config - {data.database.name} - Profilarr</title>
</svelte:head>
<div class="mt-6">
{#if manifest}
<div class="space-y-5">
<!-- Name -->
<div class="space-y-1">
<label for="name" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Name <span class="text-red-500">*</span>
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Unique identifier for the database (lowercase, hyphens preferred)
</p>
<input
type="text"
id="name"
value={manifest.name}
oninput={(e) => update('name', (e.target as HTMLInputElement).value)}
placeholder="my-database"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
</div>
<!-- Description -->
<div class="space-y-1">
<label for="description" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Description <span class="text-red-500">*</span>
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Short summary of what the database provides
</p>
<input
type="text"
id="description"
value={manifest.description}
oninput={(e) => update('description', (e.target as HTMLInputElement).value)}
placeholder="My custom Arr configurations"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
</div>
<!-- Version -->
<div class="space-y-1">
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Version <span class="text-red-500">*</span>
</span>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Semantic version of the database (MAJOR.MINOR.PATCH)
</p>
<div class="mt-1 flex items-center gap-1">
<div class="w-20">
<NumberInput
name="version-major"
value={vMajor}
min={1}
font="mono"
onchange={(v) => update('version', updateVersionPart(manifest.version, 0, v))}
onMinBlocked={() => alertStore.add('warning', 'Database version must be at least 1.0.0')}
/>
</div>
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
<div class="w-20">
<NumberInput
name="version-minor"
value={vMinor}
min={0}
font="mono"
onchange={(v) => update('version', updateVersionPart(manifest.version, 1, v))}
/>
</div>
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
<div class="w-20">
<NumberInput
name="version-patch"
value={vPatch}
min={0}
font="mono"
onchange={(v) => update('version', updateVersionPart(manifest.version, 2, v))}
/>
</div>
</div>
</div>
<!-- Minimum Profilarr Version -->
<div class="space-y-1">
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Minimum Profilarr Version <span class="text-red-500">*</span>
</span>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Minimum Profilarr version required to use this database
</p>
<div class="mt-1 flex items-center gap-1">
<div class="w-20">
<NumberInput
name="profilarr-version-major"
value={pvMajor}
min={2}
font="mono"
onchange={(v) => updateProfilarr('minimum_version', updateVersionPart(manifest.profilarr.minimum_version, 0, v))}
onMinBlocked={() => alertStore.add('warning', 'Minimum Profilarr version must be at least 2.0.0')}
/>
</div>
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
<div class="w-20">
<NumberInput
name="profilarr-version-minor"
value={pvMinor}
min={0}
font="mono"
onchange={(v) => updateProfilarr('minimum_version', updateVersionPart(manifest.profilarr.minimum_version, 1, v))}
/>
</div>
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
<div class="w-20">
<NumberInput
name="profilarr-version-patch"
value={pvPatch}
min={0}
font="mono"
onchange={(v) => updateProfilarr('minimum_version', updateVersionPart(manifest.profilarr.minimum_version, 2, v))}
/>
</div>
</div>
</div>
<!-- Arr Types -->
<div class="space-y-1">
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Arr Types
</span>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Which arr applications this database supports. Leave empty if all are supported.
</p>
<div class="mt-1">
<TagInput
tags={manifest.arr_types ?? []}
placeholder="Add arr type (radarr, sonarr, etc.)"
onchange={(tags) => update('arr_types', tags)}
/>
</div>
</div>
<!-- Tags -->
<div class="space-y-1">
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Tags</span>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Descriptive keywords for discovery
</p>
<div class="mt-1">
<TagInput
tags={manifest.tags ?? []}
placeholder="Add tags..."
onchange={(tags) => update('tags', tags)}
/>
</div>
</div>
<!-- License -->
<div class="space-y-1">
<label for="license" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
License
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
SPDX license identifier (e.g., MIT, Apache-2.0)
</p>
<input
type="text"
id="license"
value={manifest.license ?? ''}
oninput={(e) => update('license', (e.target as HTMLInputElement).value || undefined)}
placeholder="MIT"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
</div>
<!-- Repository -->
<div class="space-y-1">
<label for="repository" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Repository
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Git repository URL
</p>
<input
type="url"
id="repository"
value={manifest.repository ?? ''}
oninput={(e) => update('repository', (e.target as HTMLInputElement).value || undefined)}
placeholder="https://github.com/user/repo"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
</div>
<!-- Dependencies -->
<KeyValueList
label="Dependencies"
description="Dependencies this database requires. All PCDs must depend on schema at minimum. Additional dependencies coming in a future version."
keyLabel="Package"
valueLabel="Version"
keyPlaceholder="package-name"
valueType="version"
versionMinMajor={1}
value={manifest.dependencies ?? {}}
onchange={(v) => update('dependencies', v)}
lockedFirst={{ key: 'https://github.com/Dictionarry-Hub/schema', value: '1.0.0', minMajor: 1 }}
onLockedEditAttempt={() => alertStore.add('warning', 'The schema package URL cannot be changed')}
onLockedDeleteAttempt={() => alertStore.add('warning', 'The schema dependency is required and cannot be removed')}
onLockedVersionMinBlocked={() => alertStore.add('warning', 'Schema version must be at least 1.0.0')}
addDisabled={true}
onAddBlocked={() => alertStore.add('info', 'Additional dependencies are not available yet. Coming in a future version.')}
/>
<!-- README -->
<div class="space-y-1">
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">README</span>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Documentation for your database
</p>
<div class="mt-1">
<MarkdownInput
bind:value={readme}
placeholder="Write your README here..."
rows={12}
autoResize={false}
/>
</div>
</div>
</div>
{:else}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No manifest found</p>
{/if}
</div>