feat: backend support for manifest/readme updates

This commit is contained in:
Sam Chau
2026-01-17 00:40:03 +10:30
parent 4186e1413a
commit 3d1e55e46c
8 changed files with 263 additions and 211 deletions

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
export let variant: 'accent' | 'neutral' | 'success' | 'warning' | 'danger' = 'accent';
export let variant: 'accent' | 'neutral' | 'success' | 'warning' | 'danger' | 'info' = 'accent';
export let size: 'sm' | 'md' = 'sm';
export let icon: ComponentType | null = null;
export let mono: boolean = false;
@@ -11,7 +11,8 @@
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'
danger: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
};
const sizeClasses: Record<typeof size, string> = {

View File

@@ -3,6 +3,8 @@
* Handles reading and validating pcd.json files
*/
import { logger } from '$logger/logger.ts';
export interface Manifest {
name: string;
version: string;
@@ -150,3 +152,16 @@ export async function loadManifest(pcdPath: string): Promise<Manifest> {
validateManifest(manifest);
return manifest;
}
/**
* Write manifest to a PCD repository
*/
export async function writeManifest(pcdPath: string, manifest: Manifest): Promise<void> {
validateManifest(manifest);
const manifestPath = `${pcdPath}/pcd.json`;
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await logger.info(`Wrote manifest: ${manifest.name}`, {
source: 'PCDManifest',
meta: { path: pcdPath, manifest }
});
}

View File

@@ -0,0 +1,28 @@
/**
* PCD README Handler
* Handles reading and writing README.md files for PCD repositories
*/
import { logger } from '$logger/logger.ts';
/**
* Read README from a PCD repository
*/
export async function readReadme(pcdPath: string): Promise<string | null> {
try {
return await Deno.readTextFile(`${pcdPath}/README.md`);
} catch {
return null;
}
}
/**
* Write README to a PCD repository
*/
export async function writeReadme(pcdPath: string, content: string): Promise<void> {
await Deno.writeTextFile(`${pcdPath}/README.md`, content);
await logger.info('Wrote README', {
source: 'PCDReadme',
meta: { path: pcdPath, content }
});
}

View File

@@ -43,27 +43,60 @@ function extractOpNumber(filename: string): number {
}
/**
* Get uncommitted operation files from ops/ directory
* Get metadata for known config files
*/
function getConfigFileMetadata(filename: string, status: string): Partial<OperationFile> {
const isNew = status.includes('?') || status.includes('A');
const operation = isNew ? 'create' : 'update';
if (filename === 'pcd.json') {
return { operation, entity: 'manifest', name: 'pcd.json' };
}
if (filename === 'README.md') {
return { operation, entity: 'readme', name: 'README.md' };
}
if (filename.startsWith('tweaks/') && filename.endsWith('.sql')) {
return { operation, entity: 'tweak', name: filename.replace('tweaks/', '') };
}
return { operation, entity: null, name: filename };
}
/**
* Get uncommitted files from the repository (excludes user_ops/ and deps/)
*/
export async function getUncommittedOps(repoPath: string): Promise<OperationFile[]> {
const files: OperationFile[] = [];
const output = await execGitSafe(['status', '--porcelain', 'ops/'], repoPath);
const output = await execGitSafe(['status', '--porcelain'], repoPath);
if (!output) return files;
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const status = line.substring(0, 2);
const filename = line.substring(3).trim();
// Git porcelain format: XY PATH (2 char status + variable whitespace + path)
const rawFilename = line.substring(2).trimStart();
// Only include new/modified .sql files
if ((status.includes('?') || status.includes('A') || status.includes('M')) && filename.endsWith('.sql')) {
const filepath = `${repoPath}/${filename}`;
const metadata = await parseOperationMetadata(filepath);
// Skip user_ops and deps directories
if (rawFilename.startsWith('user_ops/') || rawFilename.startsWith('deps/')) continue;
// Only include new/modified files
if (status.includes('?') || status.includes('A') || status.includes('M')) {
const filepath = `${repoPath}/${rawFilename}`;
let metadata: Partial<OperationFile> = {};
if (rawFilename.startsWith('ops/') && rawFilename.endsWith('.sql')) {
// Parse metadata from SQL file header
metadata = await parseOperationMetadata(filepath);
} else {
// Get metadata for config files
metadata = getConfigFileMetadata(rawFilename, status);
}
files.push({
filename: filename.replace('ops/', ''),
filename: rawFilename.startsWith('ops/') ? rawFilename.replace('ops/', '') : rawFilename,
filepath,
operation: metadata.operation || null,
entity: metadata.entity || null,
@@ -73,8 +106,18 @@ export async function getUncommittedOps(repoPath: string): Promise<OperationFile
}
}
// Sort by operation number (natural sort)
files.sort((a, b) => extractOpNumber(a.filename) - extractOpNumber(b.filename));
// Sort: ops/ files by number first, then other files alphabetically
files.sort((a, b) => {
const aIsOps = a.filepath.includes('/ops/');
const bIsOps = b.filepath.includes('/ops/');
if (aIsOps && bIsOps) {
return extractOpNumber(a.filename) - extractOpNumber(b.filename);
}
if (aIsOps) return -1;
if (bIsOps) return 1;
return a.filename.localeCompare(b.filename);
});
return files;
}
@@ -103,25 +146,37 @@ export async function getMaxOpNumber(repoPath: string): Promise<number> {
}
/**
* Discard operation files (delete them)
* Discard uncommitted files (restore or delete them)
*/
export async function discardOps(repoPath: string, filepaths: string[]): Promise<void> {
for (const filepath of filepaths) {
// Security: ensure file is within ops directory
if (!filepath.startsWith(repoPath + '/ops/')) {
// Security: ensure file is within repo and not in user_ops or deps
if (!filepath.startsWith(repoPath + '/') ||
filepath.startsWith(repoPath + '/user_ops/') ||
filepath.startsWith(repoPath + '/deps/')) {
continue;
}
try {
await Deno.remove(filepath);
// Check if file is tracked by git
const relativePath = filepath.replace(repoPath + '/', '');
const isTracked = await execGitSafe(['ls-files', relativePath], repoPath);
if (isTracked?.trim()) {
// Tracked file - restore from git
await execGitSafe(['checkout', 'HEAD', '--', relativePath], repoPath);
} else {
// Untracked file - delete it
await Deno.remove(filepath);
}
} catch {
// File might already be deleted
// File might already be deleted or restored
}
}
}
/**
* Add operation files: pull, renumber if needed, commit, push
* Add files: pull, renumber ops if needed, commit, push
*
* TODO: This functionality needs to be redesigned. The current approach of
* pull -> renumber -> commit -> push has race conditions and edge cases that
@@ -148,32 +203,43 @@ export async function addOps(
// 2. Get max op number after pull
let maxNum = await getMaxOpNumber(repoPath);
// 3. Renumber and collect files to stage
// 3. Process files - renumber ops/, keep others as-is
const filesToStage: string[] = [];
const opsPath = `${repoPath}/ops`;
for (const filepath of filepaths) {
if (!filepath.startsWith(repoPath + '/ops/')) continue;
// Security: ensure file is within repo and not in user_ops or deps
if (!filepath.startsWith(repoPath + '/') ||
filepath.startsWith(repoPath + '/user_ops/') ||
filepath.startsWith(repoPath + '/deps/')) {
continue;
}
const filename = filepath.split('/').pop()!;
const match = filename.match(/^(\d+)\.(.+)$/);
// Handle ops/ files with renumbering
if (filepath.startsWith(repoPath + '/ops/')) {
const filename = filepath.split('/').pop()!;
const match = filename.match(/^(\d+)\.(.+)$/);
if (match) {
const currentNum = parseInt(match[1], 10);
const rest = match[2];
if (match) {
const currentNum = parseInt(match[1], 10);
const rest = match[2];
if (currentNum <= maxNum) {
// Need to renumber
maxNum++;
const newFilename = `${maxNum}.${rest}`;
const newFilepath = `${opsPath}/${newFilename}`;
await Deno.rename(filepath, newFilepath);
filesToStage.push(newFilepath);
if (currentNum <= maxNum) {
// Need to renumber
maxNum++;
const newFilename = `${maxNum}.${rest}`;
const newFilepath = `${opsPath}/${newFilename}`;
await Deno.rename(filepath, newFilepath);
filesToStage.push(newFilepath);
} else {
maxNum = Math.max(maxNum, currentNum);
filesToStage.push(filepath);
}
} else {
maxNum = Math.max(maxNum, currentNum);
filesToStage.push(filepath);
}
} else {
// Non-ops files - stage as-is
filesToStage.push(filepath);
}
}

View File

@@ -135,10 +135,10 @@
{row.name}
</div>
{#if row.is_private}
<Badge variant="warning" icon={Lock} mono>Private</Badge>
<Badge variant="neutral" icon={Lock} mono>Private</Badge>
{/if}
{#if row.personal_access_token}
<Badge variant="success" icon={Code} mono>Dev</Badge>
<Badge variant="info" icon={Code} mono>Dev</Badge>
{/if}
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { readManifest, type Manifest } from '$lib/server/pcd/manifest.ts';
import { error, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { readManifest, writeManifest, validateManifest, type Manifest } from '$lib/server/pcd/manifest.ts';
import { readReadme, writeReadme } from '$lib/server/pcd/readme.ts';
import { parseMarkdown } from '$utils/markdown/markdown.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
export const load: PageServerLoad = async ({ parent }) => {
const { database } = await parent();
@@ -20,11 +22,9 @@ export const load: PageServerLoad = async ({ parent }) => {
// Manifest might not exist yet
}
try {
readmeRaw = await Deno.readTextFile(`${database.local_path}/README.md`);
readmeRaw = await readReadme(database.local_path);
if (readmeRaw) {
readmeHtml = parseMarkdown(readmeRaw);
} catch {
// README might not exist
}
return {
@@ -33,3 +33,34 @@ export const load: PageServerLoad = async ({ parent }) => {
readmeHtml
};
};
export const actions: Actions = {
save: async ({ request, params }) => {
const id = parseInt(params.id, 10);
const database = databaseInstancesQueries.getById(id);
if (!database) {
return fail(404, { error: 'Database not found' });
}
if (!database.personal_access_token) {
return fail(403, { error: 'Personal access token required' });
}
const formData = await request.formData();
const manifestJson = formData.get('manifest') as string;
const readme = formData.get('readme') as string;
try {
const manifest = JSON.parse(manifestJson);
validateManifest(manifest);
await writeManifest(database.local_path, manifest);
await writeReadme(database.local_path, readme);
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return fail(400, { error: message });
}
}
};

View File

@@ -1,19 +1,42 @@
<script lang="ts">
import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte';
import { enhance } from '$app/forms';
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 DirtyModal from '$ui/modal/DirtyModal.svelte';
import { alertStore } from '$lib/client/alerts/store';
import {
isDirty,
initEdit,
update as dirtyUpdate,
resetFromServer,
clear as clearDirty
} from '$lib/client/stores/dirty';
export let data: PageData;
let manifest = data.manifest;
let readme = data.readmeRaw ?? '';
let saving = false;
function update<K extends keyof NonNullable<typeof manifest>>(key: K, value: NonNullable<typeof manifest>[K]) {
// Initialize dirty tracking
onMount(() => {
if (manifest) {
initEdit({ manifest, readme });
}
});
onDestroy(() => {
clearDirty();
});
function updateManifest<K extends keyof NonNullable<typeof manifest>>(key: K, value: NonNullable<typeof manifest>[K]) {
if (!manifest) return;
manifest = { ...manifest, [key]: value };
dirtyUpdate('manifest', manifest);
}
function updateProfilarr(key: 'minimum_version', value: string) {
@@ -22,6 +45,12 @@
...manifest,
profilarr: { ...manifest.profilarr, [key]: value }
};
dirtyUpdate('manifest', manifest);
}
function updateReadme(value: string) {
readme = value;
dirtyUpdate('readme', readme);
}
function parseVersion(v: string): [number, number, number] {
@@ -43,7 +72,26 @@
<title>Config - {data.database.name} - Profilarr</title>
</svelte:head>
<div class="mt-6">
<form
method="POST"
action="?/save"
use:enhance={() => {
saving = true;
return async ({ result }) => {
saving = false;
if (result.type === 'success') {
alertStore.add('success', 'Config saved successfully');
resetFromServer({ manifest, readme });
} else if (result.type === 'failure') {
alertStore.add('error', (result.data as { error?: string })?.error || 'Failed to save config');
}
};
}}
class="mt-6"
>
<input type="hidden" name="manifest" value={JSON.stringify(manifest)} />
<input type="hidden" name="readme" value={readme} />
{#if manifest}
<div class="space-y-5">
<!-- Name -->
@@ -58,7 +106,7 @@
type="text"
id="name"
value={manifest.name}
oninput={(e) => update('name', (e.target as HTMLInputElement).value)}
oninput={(e) => updateManifest('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"
/>
@@ -76,7 +124,7 @@
type="text"
id="description"
value={manifest.description}
oninput={(e) => update('description', (e.target as HTMLInputElement).value)}
oninput={(e) => updateManifest('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"
/>
@@ -97,7 +145,7 @@
value={vMajor}
min={1}
font="mono"
onchange={(v) => update('version', updateVersionPart(manifest.version, 0, v))}
onchange={(v) => updateManifest('version', updateVersionPart(manifest.version, 0, v))}
onMinBlocked={() => alertStore.add('warning', 'Database version must be at least 1.0.0')}
/>
</div>
@@ -108,7 +156,7 @@
value={vMinor}
min={0}
font="mono"
onchange={(v) => update('version', updateVersionPart(manifest.version, 1, v))}
onchange={(v) => updateManifest('version', updateVersionPart(manifest.version, 1, v))}
/>
</div>
<span class="text-lg font-medium text-neutral-400 dark:text-neutral-500">.</span>
@@ -118,7 +166,7 @@
value={vPatch}
min={0}
font="mono"
onchange={(v) => update('version', updateVersionPart(manifest.version, 2, v))}
onchange={(v) => updateManifest('version', updateVersionPart(manifest.version, 2, v))}
/>
</div>
</div>
@@ -178,7 +226,7 @@
<TagInput
tags={manifest.arr_types ?? []}
placeholder="Add arr type (radarr, sonarr, etc.)"
onchange={(tags) => update('arr_types', tags)}
onchange={(tags) => updateManifest('arr_types', tags)}
/>
</div>
</div>
@@ -193,7 +241,7 @@
<TagInput
tags={manifest.tags ?? []}
placeholder="Add tags..."
onchange={(tags) => update('tags', tags)}
onchange={(tags) => updateManifest('tags', tags)}
/>
</div>
</div>
@@ -210,7 +258,7 @@
type="text"
id="license"
value={manifest.license ?? ''}
oninput={(e) => update('license', (e.target as HTMLInputElement).value || undefined)}
oninput={(e) => updateManifest('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"
/>
@@ -228,7 +276,7 @@
type="url"
id="repository"
value={manifest.repository ?? ''}
oninput={(e) => update('repository', (e.target as HTMLInputElement).value || undefined)}
oninput={(e) => updateManifest('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"
/>
@@ -244,7 +292,7 @@
valueType="version"
versionMinMajor={1}
value={manifest.dependencies ?? {}}
onchange={(v) => update('dependencies', v)}
onchange={(v) => updateManifest('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')}
@@ -261,15 +309,28 @@
</p>
<div class="mt-1">
<MarkdownInput
bind:value={readme}
value={readme}
onchange={updateReadme}
placeholder="Write your README here..."
rows={12}
autoResize={false}
/>
</div>
</div>
<!-- Save Button -->
<div class="sticky bottom-0 -mx-6 border-t border-neutral-200 bg-neutral-50 px-6 py-4 dark:border-neutral-700 dark:bg-neutral-900">
<button
type="submit"
disabled={!$isDirty || saving}
class="rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-600 dark:hover:bg-accent-500"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
{:else}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No manifest found</p>
{/if}
</div>
</form>
<DirtyModal />

View File

@@ -1,167 +1,17 @@
<script lang="ts">
import { Save, Check, Code } from 'lucide-svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import type { Column } from '$ui/table/types';
import type { PageData } from './$types';
export let data: PageData;
// Mock tweak data
type Tweak = {
id: string;
name: string;
description: string;
sql: string;
};
const mockTweaks: Tweak[] = [
{
id: 'enable-prereleases',
name: 'Enable Prereleases',
description: 'Adds a Prereleases quality group (CAM, Telesync, DVD Screener, etc.) to the bottom of all quality profiles, allowing early access to new releases.',
sql: `-- Enable Prereleases
-- Creates a Prereleases quality group for each profile and places it
-- below the last enabled quality/group
-- Step 1: Create Prereleases group for each quality profile
INSERT INTO quality_groups (quality_profile_id, name)
SELECT id, 'Prereleases'
FROM quality_profiles
WHERE id NOT IN (
SELECT quality_profile_id FROM quality_groups WHERE name = 'Prereleases'
);
-- Step 2: Add prerelease qualities to the group
INSERT INTO quality_group_members (quality_group_id, quality_id)
SELECT qg.id, q.id
FROM quality_groups qg
CROSS JOIN qualities q
WHERE qg.name = 'Prereleases'
AND q.name IN ('CAM', 'TELESYNC', 'TELECINE', 'DVDSCR', 'REGIONAL', 'WORKPRINT')
AND NOT EXISTS (
SELECT 1 FROM quality_group_members qgm
WHERE qgm.quality_group_id = qg.id AND qgm.quality_id = q.id
);
-- Step 3: Add the group to each profile's quality list
-- Position it below the last enabled quality/group
INSERT INTO quality_profile_qualities (quality_profile_id, quality_group_id, position, enabled)
SELECT
qg.quality_profile_id,
qg.id,
COALESCE(
(SELECT MAX(position) + 1
FROM quality_profile_qualities
WHERE quality_profile_id = qg.quality_profile_id AND enabled = 1),
1
),
1
FROM quality_groups qg
WHERE qg.name = 'Prereleases'
AND NOT EXISTS (
SELECT 1 FROM quality_profile_qualities qpq
WHERE qpq.quality_group_id = qg.id
);`
}
];
// Track enabled state (not persisted)
let enabledTweaks: Set<string> = new Set();
function toggleTweak(id: string) {
if (enabledTweaks.has(id)) {
enabledTweaks.delete(id);
} else {
enabledTweaks.add(id);
}
enabledTweaks = enabledTweaks;
}
const columns: Column<Tweak>[] = [
{
key: 'name',
header: 'Name',
sortable: true
},
{
key: 'description',
header: 'Description'
}
];
function getRowId(row: Tweak): string {
return row.id;
}
$: hasChanges = enabledTweaks.size > 0;
</script>
<svelte:head>
<title>Tweaks - {data.database.name} - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-4">
<!-- Header with Save -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Tweaks</h1>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Optional modifications curated by databases. Enable tweaks to adjust quality profile behaviour.
</p>
</div>
<button
type="button"
disabled={!hasChanges}
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
<Save size={16} />
Save
</button>
<div class="mt-6">
<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-500 dark:text-neutral-400">
No tweaks available for this database.
</p>
</div>
<!-- Tweaks Table -->
{#if mockTweaks.length > 0}
<ExpandableTable
{columns}
data={mockTweaks}
{getRowId}
compact={true}
flushExpanded={true}
emptyMessage="No tweaks available"
chevronPosition="right"
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'name'}
<span class="font-medium text-neutral-900 dark:text-neutral-100">{row.name}</span>
{:else if column.key === 'description'}
<span class="text-sm text-neutral-600 dark:text-neutral-400">{row.description}</span>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<IconCheckbox
icon={Check}
checked={enabledTweaks.has(row.id)}
on:click={() => toggleTweak(row.id)}
/>
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="px-6 py-4">
<div class="flex items-center gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">
<Code size={12} />
SQL
</div>
<pre class="rounded-lg bg-neutral-100 p-3 text-xs font-mono text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200 overflow-x-auto">{row.sql}</pre>
</div>
</svelte:fragment>
</ExpandableTable>
{:else}
<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-500 dark:text-neutral-400">
No tweaks available for this database.
</p>
</div>
{/if}
</div>