mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-29 14:00:52 +01:00
feat: add database configuration page with manifest and README support
This commit is contained in:
249
src/app.css
249
src/app.css
@@ -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;
|
||||
|
||||
213
src/lib/client/ui/form/KeyValueList.svelte
Normal file
213
src/lib/client/ui/form/KeyValueList.svelte
Normal 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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 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 -->
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
35
src/routes/databases/[id]/config/+page.server.ts
Normal file
35
src/routes/databases/[id]/config/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
275
src/routes/databases/[id]/config/+page.svelte
Normal file
275
src/routes/databases/[id]/config/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user