feat(ui): add content editor components for skills and thoughts
Some checks failed
CI / build-and-test (push) Failing after -31m24s
Some checks failed
CI / build-and-test (push) Failing after -31m24s
* Implement ContentEditorField for inline editing of content * Create ContentEditorModal for editing content in a modal * Introduce FormerShell for managing forms related to skills and thoughts * Enhance SkillsPage and ThoughtsPage with new components for better content management
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -9,7 +9,7 @@
|
||||
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-slate-950">
|
||||
<body class="bg-slate-950" data-theme="amcs">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
"vite": "^8.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/svelte": "^10.50.0",
|
||||
"@sentry/svelte": "^10.51.0",
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@tanstack/svelte-virtual": "^3.13.24",
|
||||
"@warkypublic/artemis-kit": "^1.0.10",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"@warkypublic/svelix": "^0.1.39"
|
||||
"@warkypublic/svelix": "^0.1.40"
|
||||
}
|
||||
}
|
||||
}
|
||||
82
ui/pnpm-lock.yaml
generated
82
ui/pnpm-lock.yaml
generated
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@sentry/svelte':
|
||||
specifier: ^10.50.0
|
||||
version: 10.50.0(svelte@5.55.5)
|
||||
specifier: ^10.51.0
|
||||
version: 10.51.0(svelte@5.55.5)
|
||||
'@skeletonlabs/skeleton':
|
||||
specifier: ^4.15.2
|
||||
version: 4.15.2(tailwindcss@4.2.4)
|
||||
@@ -27,8 +27,8 @@ importers:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@warkypublic/svelix':
|
||||
specifier: ^0.1.39
|
||||
version: 0.1.39(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)
|
||||
specifier: ^0.1.40
|
||||
version: 0.1.40(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)
|
||||
devDependencies:
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^7.0.0
|
||||
@@ -315,32 +315,32 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
|
||||
|
||||
'@sentry-internal/browser-utils@10.50.0':
|
||||
resolution: {integrity: sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA==}
|
||||
'@sentry-internal/browser-utils@10.51.0':
|
||||
resolution: {integrity: sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/feedback@10.50.0':
|
||||
resolution: {integrity: sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w==}
|
||||
'@sentry-internal/feedback@10.51.0':
|
||||
resolution: {integrity: sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay-canvas@10.50.0':
|
||||
resolution: {integrity: sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw==}
|
||||
'@sentry-internal/replay-canvas@10.51.0':
|
||||
resolution: {integrity: sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay@10.50.0':
|
||||
resolution: {integrity: sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ==}
|
||||
'@sentry-internal/replay@10.51.0':
|
||||
resolution: {integrity: sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/browser@10.50.0':
|
||||
resolution: {integrity: sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA==}
|
||||
'@sentry/browser@10.51.0':
|
||||
resolution: {integrity: sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/core@10.50.0':
|
||||
resolution: {integrity: sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg==}
|
||||
'@sentry/core@10.51.0':
|
||||
resolution: {integrity: sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/svelte@10.50.0':
|
||||
resolution: {integrity: sha512-pkd9HNpZN+8x9i8n24fpV+Q3/sKDkBKyJ29iNzbhGnZ3CeRPwKxwQOoiBBPQkllYWzr/a7cYFtBKNLdpmTFCOg==}
|
||||
'@sentry/svelte@10.51.0':
|
||||
resolution: {integrity: sha512-2/OwIs+WXk+H/CAnWeiQMvXx5EG8LxCtqu4m+HdHtJTUQtERC388zNShZTJU9YYR71KoDOVAU0Q6B2sKNcssFA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
svelte: 3.x || 4.x || 5.x
|
||||
@@ -758,8 +758,8 @@ packages:
|
||||
resolution: {integrity: sha512-uXP1HouxpOKXfwE6qpy0gCcrMPIgjDT53aVGkfork4QejRSunbKWSKKawW2nIm7RnyFhSjPILMXcnT5xUiXOew==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@warkypublic/svelix@0.1.39':
|
||||
resolution: {integrity: sha512-CeKOyabAXTt5MXzRkQyG0G7+1wwgYD/e4+fX9gRsQWxlVVrHv8qdbK1HxHHKMmu4ZW9csf0dXWcEIt/TSM9qAg==}
|
||||
'@warkypublic/svelix@0.1.40':
|
||||
resolution: {integrity: sha512-Pn4T+VVI1Pfrtkbr61oqVyhaUTySU0r6gti9SeSqL+ZJeRfXAj+ZTuwlHzk0w9BxrwlWDnVinGyrpbSjnwb+iQ==}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
@@ -2062,38 +2062,38 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.17': {}
|
||||
|
||||
'@sentry-internal/browser-utils@10.50.0':
|
||||
'@sentry-internal/browser-utils@10.51.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry-internal/feedback@10.50.0':
|
||||
'@sentry-internal/feedback@10.51.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry-internal/replay-canvas@10.50.0':
|
||||
'@sentry-internal/replay-canvas@10.51.0':
|
||||
dependencies:
|
||||
'@sentry-internal/replay': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry-internal/replay': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry-internal/replay@10.50.0':
|
||||
'@sentry-internal/replay@10.51.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry-internal/browser-utils': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry/browser@10.50.0':
|
||||
'@sentry/browser@10.51.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.50.0
|
||||
'@sentry-internal/feedback': 10.50.0
|
||||
'@sentry-internal/replay': 10.50.0
|
||||
'@sentry-internal/replay-canvas': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry-internal/browser-utils': 10.51.0
|
||||
'@sentry-internal/feedback': 10.51.0
|
||||
'@sentry-internal/replay': 10.51.0
|
||||
'@sentry-internal/replay-canvas': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry/core@10.50.0': {}
|
||||
'@sentry/core@10.51.0': {}
|
||||
|
||||
'@sentry/svelte@10.50.0(svelte@5.55.5)':
|
||||
'@sentry/svelte@10.51.0(svelte@5.55.5)':
|
||||
dependencies:
|
||||
'@sentry/browser': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry/browser': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
magic-string: 0.30.21
|
||||
svelte: 5.55.5
|
||||
|
||||
@@ -2556,7 +2556,7 @@ snapshots:
|
||||
dependencies:
|
||||
uuid: 13.0.0
|
||||
|
||||
'@warkypublic/svelix@0.1.39(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)':
|
||||
'@warkypublic/svelix@0.1.40(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)':
|
||||
dependencies:
|
||||
'@cartamd/plugin-anchor': 2.2.0(carta-md@4.11.2(svelte@5.55.5))
|
||||
'@cartamd/plugin-attachment': 4.2.0(carta-md@4.11.2(svelte@5.55.5))
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
<title>AMCS Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<div data-theme="amcs" class="min-h-screen bg-slate-950 text-slate-100">
|
||||
{#if !isLoggedIn.current}
|
||||
<LoginPage
|
||||
{isOAuthCallback}
|
||||
|
||||
218
ui/src/amcs.theme.css
Normal file
218
ui/src/amcs.theme.css
Normal file
@@ -0,0 +1,218 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
[data-theme='amcs'] {
|
||||
--text-scaling: 1;
|
||||
--base-font-color: var(--color-surface-950);
|
||||
--base-font-color-dark: var(--color-surface-50);
|
||||
--base-font-family: 'Inter', system-ui, sans-serif;
|
||||
--base-font-size: inherit;
|
||||
--base-line-height: 1.5;
|
||||
--base-font-weight: 400;
|
||||
--base-font-style: normal;
|
||||
--base-letter-spacing: 0;
|
||||
--heading-font-color: inherit;
|
||||
--heading-font-color-dark: inherit;
|
||||
--heading-font-family: 'Inter', system-ui, sans-serif;
|
||||
--heading-font-weight: 600;
|
||||
--heading-font-style: normal;
|
||||
--heading-letter-spacing: -0.01em;
|
||||
--anchor-font-color: var(--color-primary-400);
|
||||
--anchor-font-color-dark: var(--color-primary-300);
|
||||
--anchor-font-family: inherit;
|
||||
--anchor-font-size: inherit;
|
||||
--anchor-line-height: inherit;
|
||||
--anchor-font-weight: inherit;
|
||||
--anchor-font-style: inherit;
|
||||
--anchor-letter-spacing: inherit;
|
||||
--anchor-text-decoration: none;
|
||||
--anchor-text-decoration-hover: underline;
|
||||
--anchor-text-decoration-active: none;
|
||||
--anchor-text-decoration-focus: none;
|
||||
--spacing: 0.25rem;
|
||||
--radius-base: 0.75rem;
|
||||
--radius-container: 1rem;
|
||||
--default-border-width: 1px;
|
||||
--default-divide-width: 1px;
|
||||
--default-ring-width: 1px;
|
||||
--body-background-color: var(--color-surface-50);
|
||||
--body-background-color-dark: var(--color-surface-950);
|
||||
|
||||
/* Primary: AMCS cyan */
|
||||
--color-primary-50: oklch(98.4% 0.019 200.87deg);
|
||||
--color-primary-100: oklch(95.6% 0.045 203.39deg);
|
||||
--color-primary-200: oklch(91.7% 0.08 205.04deg);
|
||||
--color-primary-300: oklch(86.5% 0.127 207.08deg);
|
||||
--color-primary-400: oklch(78.9% 0.154 211.53deg);
|
||||
--color-primary-500: oklch(71.5% 0.143 215.22deg);
|
||||
--color-primary-600: oklch(60.9% 0.126 221.72deg);
|
||||
--color-primary-700: oklch(51.9% 0.105 223.13deg);
|
||||
--color-primary-800: oklch(45% 0.085 224.28deg);
|
||||
--color-primary-900: oklch(39.8% 0.07 227.39deg);
|
||||
--color-primary-950: oklch(30.2% 0.056 229.7deg);
|
||||
--color-primary-contrast-dark: var(--color-primary-950);
|
||||
--color-primary-contrast-light: var(--color-primary-50);
|
||||
--color-primary-contrast-50: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-100: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-200: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-300: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-400: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-500: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-600: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-700: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-800: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-900: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-950: var(--color-primary-contrast-light);
|
||||
|
||||
/* Secondary: deeper steel blue */
|
||||
--color-secondary-50: oklch(97.7% 0.006 247.9deg);
|
||||
--color-secondary-100: oklch(95.2% 0.011 250.1deg);
|
||||
--color-secondary-200: oklch(90.7% 0.021 252.8deg);
|
||||
--color-secondary-300: oklch(83.8% 0.036 254.6deg);
|
||||
--color-secondary-400: oklch(70.4% 0.05 256.7deg);
|
||||
--color-secondary-500: oklch(55.4% 0.046 257.4deg);
|
||||
--color-secondary-600: oklch(46.1% 0.043 257.3deg);
|
||||
--color-secondary-700: oklch(38.7% 0.044 257.3deg);
|
||||
--color-secondary-800: oklch(30.5% 0.043 260deg);
|
||||
--color-secondary-900: oklch(24.6% 0.042 264deg);
|
||||
--color-secondary-950: oklch(17.8% 0.041 265deg);
|
||||
--color-secondary-contrast-dark: var(--color-secondary-950);
|
||||
--color-secondary-contrast-light: var(--color-secondary-50);
|
||||
--color-secondary-contrast-50: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-100: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-200: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-300: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-400: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-500: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-600: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-700: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-800: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-900: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-950: var(--color-secondary-contrast-light);
|
||||
|
||||
/* Tertiary: bright highlight cyan */
|
||||
--color-tertiary-50: oklch(98.5% 0.02 214deg);
|
||||
--color-tertiary-100: oklch(96.2% 0.05 214deg);
|
||||
--color-tertiary-200: oklch(92.4% 0.088 213deg);
|
||||
--color-tertiary-300: oklch(87.1% 0.132 212deg);
|
||||
--color-tertiary-400: oklch(80.2% 0.157 211deg);
|
||||
--color-tertiary-500: oklch(73.8% 0.145 210deg);
|
||||
--color-tertiary-600: oklch(64.2% 0.126 214deg);
|
||||
--color-tertiary-700: oklch(55.2% 0.107 218deg);
|
||||
--color-tertiary-800: oklch(46.1% 0.087 221deg);
|
||||
--color-tertiary-900: oklch(38.7% 0.07 225deg);
|
||||
--color-tertiary-950: oklch(29.1% 0.053 229deg);
|
||||
--color-tertiary-contrast-dark: var(--color-tertiary-950);
|
||||
--color-tertiary-contrast-light: var(--color-tertiary-50);
|
||||
--color-tertiary-contrast-50: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-100: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-200: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-300: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-400: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-500: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-600: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-700: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-800: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-900: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-950: var(--color-tertiary-contrast-light);
|
||||
|
||||
--color-success-50: oklch(97.9% 0.021 166deg);
|
||||
--color-success-100: oklch(95.1% 0.05 167deg);
|
||||
--color-success-200: oklch(90.5% 0.09 166deg);
|
||||
--color-success-300: oklch(84.2% 0.137 165deg);
|
||||
--color-success-400: oklch(76.5% 0.146 164deg);
|
||||
--color-success-500: oklch(69.6% 0.135 163deg);
|
||||
--color-success-600: oklch(59.6% 0.115 163deg);
|
||||
--color-success-700: oklch(50.8% 0.096 164deg);
|
||||
--color-success-800: oklch(42.7% 0.077 165deg);
|
||||
--color-success-900: oklch(35.4% 0.061 166deg);
|
||||
--color-success-950: oklch(24.5% 0.041 168deg);
|
||||
--color-success-contrast-dark: var(--color-success-950);
|
||||
--color-success-contrast-light: var(--color-success-50);
|
||||
--color-success-contrast-50: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-100: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-200: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-300: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-400: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-500: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-600: var(--color-success-contrast-light);
|
||||
--color-success-contrast-700: var(--color-success-contrast-light);
|
||||
--color-success-contrast-800: var(--color-success-contrast-light);
|
||||
--color-success-contrast-900: var(--color-success-contrast-light);
|
||||
--color-success-contrast-950: var(--color-success-contrast-light);
|
||||
|
||||
--color-warning-50: oklch(98.7% 0.022 95deg);
|
||||
--color-warning-100: oklch(96.2% 0.05 93deg);
|
||||
--color-warning-200: oklch(92.5% 0.095 90deg);
|
||||
--color-warning-300: oklch(87.9% 0.145 86deg);
|
||||
--color-warning-400: oklch(82.8% 0.168 81deg);
|
||||
--color-warning-500: oklch(76.9% 0.164 74deg);
|
||||
--color-warning-600: oklch(66.6% 0.149 66deg);
|
||||
--color-warning-700: oklch(56.3% 0.13 60deg);
|
||||
--color-warning-800: oklch(47.8% 0.111 56deg);
|
||||
--color-warning-900: oklch(40.5% 0.094 53deg);
|
||||
--color-warning-950: oklch(28.2% 0.066 49deg);
|
||||
--color-warning-contrast-dark: var(--color-warning-950);
|
||||
--color-warning-contrast-light: var(--color-warning-50);
|
||||
--color-warning-contrast-50: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-100: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-200: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-300: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-400: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-500: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-600: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-700: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-800: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-900: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-950: var(--color-warning-contrast-light);
|
||||
|
||||
--color-error-50: oklch(97.1% 0.014 17deg);
|
||||
--color-error-100: oklch(93.7% 0.032 18deg);
|
||||
--color-error-200: oklch(88.5% 0.062 19deg);
|
||||
--color-error-300: oklch(81.4% 0.104 21deg);
|
||||
--color-error-400: oklch(71.2% 0.164 24deg);
|
||||
--color-error-500: oklch(63.7% 0.208 25deg);
|
||||
--color-error-600: oklch(57.7% 0.204 26deg);
|
||||
--color-error-700: oklch(50.5% 0.182 27deg);
|
||||
--color-error-800: oklch(44.4% 0.156 27deg);
|
||||
--color-error-900: oklch(39.6% 0.129 28deg);
|
||||
--color-error-950: oklch(25.8% 0.082 28deg);
|
||||
--color-error-contrast-dark: var(--color-error-950);
|
||||
--color-error-contrast-light: var(--color-error-50);
|
||||
--color-error-contrast-50: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-100: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-200: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-300: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-400: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-500: var(--color-error-contrast-light);
|
||||
--color-error-contrast-600: var(--color-error-contrast-light);
|
||||
--color-error-contrast-700: var(--color-error-contrast-light);
|
||||
--color-error-contrast-800: var(--color-error-contrast-light);
|
||||
--color-error-contrast-900: var(--color-error-contrast-light);
|
||||
--color-error-contrast-950: var(--color-error-contrast-light);
|
||||
|
||||
/* Surface: slate-based AMCS dark shell */
|
||||
--color-surface-50: oklch(98.4% 0.003 247.86deg);
|
||||
--color-surface-100: oklch(96.8% 0.007 247.9deg);
|
||||
--color-surface-200: oklch(92.9% 0.013 255.51deg);
|
||||
--color-surface-300: oklch(86.9% 0.022 252.89deg);
|
||||
--color-surface-400: oklch(70.4% 0.04 256.79deg);
|
||||
--color-surface-500: oklch(55.4% 0.046 257.42deg);
|
||||
--color-surface-600: oklch(44.6% 0.043 257.28deg);
|
||||
--color-surface-700: oklch(37.2% 0.044 257.29deg);
|
||||
--color-surface-800: oklch(27.9% 0.041 260.03deg);
|
||||
--color-surface-900: oklch(20.8% 0.042 265.76deg);
|
||||
--color-surface-950: oklch(12.9% 0.042 264.7deg);
|
||||
--color-surface-contrast-dark: var(--color-surface-950);
|
||||
--color-surface-contrast-light: var(--color-surface-50);
|
||||
--color-surface-contrast-50: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-100: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-200: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-300: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-400: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-500: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-600: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-700: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-800: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-900: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-950: var(--color-surface-contrast-light);
|
||||
}
|
||||
@@ -152,7 +152,10 @@ export const api = {
|
||||
create: (name: string, description: string) =>
|
||||
rsCall<import('./types').Project>('/api/rs/public/projects', 'create', {
|
||||
data: { name, description }
|
||||
})
|
||||
}),
|
||||
update: (id: string, data: { name?: string; description?: string }) =>
|
||||
rsCall<void>(`/api/rs/public/projects/${id}`, 'update', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/projects/${id}`, 'delete')
|
||||
},
|
||||
thoughts: {
|
||||
list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => {
|
||||
@@ -232,7 +235,11 @@ export const api = {
|
||||
archive: (id: string) =>
|
||||
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
|
||||
data: { archived_at: new Date().toISOString() }
|
||||
})
|
||||
}),
|
||||
create: (data: { content: string; project_id?: string }) =>
|
||||
rsCall<import('./types').Thought>('/api/rs/public/thoughts', 'create', { data }),
|
||||
update: (id: string, data: { content?: string }) =>
|
||||
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', { data })
|
||||
},
|
||||
skills: {
|
||||
list: async (tag?: string) => {
|
||||
@@ -243,7 +250,18 @@ export const api = {
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
},
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete')
|
||||
get: async (id: string) => {
|
||||
const row = await rsCall<Omit<import('./types').AgentSkill, 'tags'> & { tags?: unknown }>(
|
||||
`/api/rs/public/agent_skills/${id}`,
|
||||
'read'
|
||||
);
|
||||
return { ...row, tags: normalizeTags(row.tags) };
|
||||
},
|
||||
create: (data: { name: string; description?: string; content: string; tags?: string[] }) =>
|
||||
rsCall<import('./types').AgentSkill>('/api/rs/public/agent_skills', 'create', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete'),
|
||||
update: (id: string, data: Partial<import('./types').AgentSkill>) =>
|
||||
rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'update', { data })
|
||||
},
|
||||
guardrails: {
|
||||
list: async (params?: { tag?: string; severity?: string }) => {
|
||||
@@ -258,7 +276,9 @@ export const api = {
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
},
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete')
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete'),
|
||||
update: (id: string, data: Partial<import('./types').AgentGuardrail>) =>
|
||||
rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'update', { data })
|
||||
},
|
||||
files: {
|
||||
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
|
||||
@@ -326,7 +346,34 @@ export const api = {
|
||||
sort: [{ column: 'updated_at', direction: 'desc' }]
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
}
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const row = await rsCall<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>(
|
||||
`/api/rs/public/plans/${id}`,
|
||||
'read'
|
||||
);
|
||||
return { ...row, tags: normalizeTags(row.tags) };
|
||||
},
|
||||
create: (data: object) =>
|
||||
rsCall<import('./types').Plan>('/api/rs/public/plans', 'create', { data }),
|
||||
update: (id: string, data: Partial<import('./types').Plan>) =>
|
||||
rsCall<void>(`/api/rs/public/plans/${id}`, 'update', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/plans/${id}`, 'delete')
|
||||
},
|
||||
learnings: {
|
||||
list: (params?: { limit?: number; offset?: number }) =>
|
||||
rsReadMany<import('./types').Learning>('learnings', {
|
||||
limit: params?.limit ?? 500,
|
||||
...(params?.offset !== undefined ? { offset: params.offset } : {}),
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
}),
|
||||
get: (id: string) =>
|
||||
rsCall<import('./types').Learning>(`/api/rs/public/learnings/${id}`, 'read'),
|
||||
create: (data: object) =>
|
||||
rsCall<import('./types').Learning>('/api/rs/public/learnings', 'create', { data }),
|
||||
update: (id: string, data: Partial<import('./types').Learning>) =>
|
||||
rsCall<void>(`/api/rs/public/learnings/${id}`, 'update', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/learnings/${id}`, 'delete')
|
||||
},
|
||||
stats: async () => {
|
||||
type StatsThoughtRow = {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
@import 'tailwindcss';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@import '@warkypublic/svelix/css/tailwind-source.css';
|
||||
@import './amcs.theme.css';
|
||||
@import '@skeletonlabs/skeleton';
|
||||
@import '@skeletonlabs/skeleton-svelte';
|
||||
@import '@skeletonlabs/skeleton/themes/cerberus';
|
||||
|
||||
@source '../node_modules/@skeletonlabs/skeleton-svelte/dist/**/*.{js,svelte,ts}';
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -14,3 +22,12 @@ body,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.input::placeholder,
|
||||
.textarea::placeholder,
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { StoredFile } from '../../types';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextAreaCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { StoredFile } from "../../types";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
|
||||
let files = $state<StoredFile[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
type FileForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
media_type: string;
|
||||
kind: string;
|
||||
project_id?: string;
|
||||
thought_id?: string;
|
||||
encoding?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
const FILE_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedFile = $state<StoredFile | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('update');
|
||||
let formValues = $state<FileForm>({ name: '', media_type: '', kind: 'file', encoding: 'base64', content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const filesOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/stored_files'
|
||||
}));
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 280 },
|
||||
{ id: 'media_type', title: 'Type', dataKey: 'media_type', width: 220 },
|
||||
{ id: 'kind', title: 'Kind', dataKey: 'kind', width: 120 },
|
||||
{ id: 'size_bytes', title: 'Size', dataKey: 'size_bytes', width: 120, format: 'number' },
|
||||
{ id: 'thought_id', title: 'Thought', dataKey: 'thought_id', width: 220 },
|
||||
{ id: 'project_id', title: 'Project', dataKey: 'project_id', width: 220 },
|
||||
{ id: 'created_at', title: 'Uploaded', dataKey: 'created_at', width: 180, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
const filesDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'stored_files',
|
||||
uniqueID: FILE_PRIMARY_KEY,
|
||||
hotfields: [FILE_PRIMARY_KEY, 'guid', 'project_id', 'thought_id'],
|
||||
columns: ['id', 'guid', 'name', 'media_type', 'kind', 'size_bytes', 'created_at', 'updated_at', 'project_id', 'thought_id'],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
columns: string[];
|
||||
};
|
||||
|
||||
function normalizeFile(rowData: Record<string, unknown>): StoredFile {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
media_type: String(rowData.media_type ?? ''),
|
||||
kind: String(rowData.kind ?? ''),
|
||||
size_bytes: Number(rowData.size_bytes ?? 0),
|
||||
thought_id: typeof rowData.thought_id === 'string' ? rowData.thought_id : undefined,
|
||||
project_id: typeof rowData.project_id === 'string' ? rowData.project_id : undefined,
|
||||
sha256: String(rowData.sha256 ?? ''),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
updated_at: String(rowData.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileRecordForFormer(data: Record<string, unknown>): FileForm {
|
||||
return {
|
||||
id: typeof data.id === 'string' || typeof data.id === 'number' ? String(data.id) : undefined,
|
||||
name: typeof data.name === 'string' ? data.name : '',
|
||||
media_type: typeof data.media_type === 'string' ? data.media_type : '',
|
||||
kind: typeof data.kind === 'string' ? data.kind : 'file',
|
||||
project_id: typeof data.project_id === 'string' && data.project_id ? data.project_id : undefined,
|
||||
thought_id: typeof data.thought_id === 'string' && data.thought_id ? data.thought_id : undefined,
|
||||
encoding: typeof data.encoding === 'string' ? data.encoding : 'base64',
|
||||
content: typeof data.content === 'string' ? data.content : ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFileFromRow(rowData: Record<string, unknown>): Promise<FileForm> {
|
||||
const id = String(rowData[FILE_PRIMARY_KEY] ?? '');
|
||||
const data = await filesOnAPICall('read', 'update', undefined, id) as Record<string, unknown>;
|
||||
return normalizeFileRecordForFormer(data);
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedFile = rowData ? normalizeFile(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (!contextRow) return;
|
||||
formValues = normalizeFileRecordForFormer(contextRow);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>
|
||||
) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}
|
||||
|
||||
function normalizeFileForm(data: FileForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
media_type: data.media_type.trim(),
|
||||
kind: data.kind.trim(),
|
||||
project_id: data.project_id?.trim() || undefined,
|
||||
thought_id: data.thought_id?.trim() || undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function handleFileSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow) {
|
||||
selectedFile = normalizeFile(contextRow);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -13,80 +164,145 @@
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
files = await api.files.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load files';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Files</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{files.length} file{files.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} file{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if files.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No files stored.</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Name</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Type</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Kind</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Size</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Uploaded</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each files as f}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="max-w-xs truncate px-4 py-3 font-medium text-white">{f.name}</td>
|
||||
<td class="px-4 py-3 text-slate-400 text-xs">{f.media_type}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">{f.kind || '—'}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{formatBytes(f.size_bytes)}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-400">{formatDate(f.created_at)}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a
|
||||
href={`/files/${f.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-cyan-400 hover:text-cyan-300"
|
||||
>↓</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="FilesGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={filesDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'media_type', 'kind', 'project_id', 'thought_id']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="text-sm font-semibold text-white">File Inspector</h3>
|
||||
{#if !selectedFile}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a file row to inspect metadata.</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedFile.name}</p>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
|
||||
<p><strong class="text-slate-100">Type:</strong> {selectedFile.media_type}</p>
|
||||
<p><strong class="text-slate-100">Kind:</strong> {selectedFile.kind || '—'}</p>
|
||||
<p><strong class="text-slate-100">Size:</strong> {formatBytes(selectedFile.size_bytes)}</p>
|
||||
<p><strong class="text-slate-100">Thought:</strong> {selectedFile.thought_id || '—'}</p>
|
||||
<p><strong class="text-slate-100">Project:</strong> {selectedFile.project_id || '—'}</p>
|
||||
<p><strong class="text-slate-100">Uploaded:</strong> {formatDate(selectedFile.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedFile.updated_at)}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href={`/files/${selectedFile.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
>Download</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="FilesFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'update' ? 'Edit File' : 'Delete File'}
|
||||
uniqueKeyField={FILE_PRIMARY_KEY}
|
||||
onAPICall={filesOnAPICall}
|
||||
afterGet={async (data) => normalizeFileRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeFileForm}
|
||||
afterSave={handleFileSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Media Type"
|
||||
name="media_type"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.media_type ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, media_type: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Kind"
|
||||
name="kind"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.kind ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, kind: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Project ID"
|
||||
name="project_id"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.project_id ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Thought ID"
|
||||
name="thought_id"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.thought_id ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, thought_id: v || undefined })}
|
||||
/>
|
||||
{#if state.request !== 'delete'}
|
||||
<TextAreaCtrl
|
||||
label="Content"
|
||||
name="content"
|
||||
rows={4}
|
||||
disabled={true}
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,103 +1,407 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { AgentGuardrail } from '../../types';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { AgentGuardrail } from "../../types";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
const severityColour: Record<string, string> = {
|
||||
low: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
|
||||
medium: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
|
||||
high: 'border-orange-400/20 bg-orange-400/10 text-orange-200',
|
||||
critical: 'border-rose-400/20 bg-rose-400/10 text-rose-200'
|
||||
type GuardrailForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
severity: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
let guardrails = $state<AgentGuardrail[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let busy = $state<string | null>(null);
|
||||
const GUARDRAIL_PRIMARY_KEY = 'id';
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
guardrails = await api.guardrails.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load guardrails';
|
||||
} finally {
|
||||
loading = false;
|
||||
const severityClasses: Record<string, string> = {
|
||||
low: 'bg-emerald-400/10 text-emerald-200',
|
||||
medium: 'bg-amber-400/10 text-amber-200',
|
||||
high: 'bg-orange-400/10 text-orange-200',
|
||||
critical: 'bg-rose-400/10 text-rose-200'
|
||||
};
|
||||
|
||||
let selectedGuardrail = $state<AgentGuardrail | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<GuardrailForm>({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
severity: 'medium',
|
||||
tags: ''
|
||||
});
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const guardrailOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_guardrails'
|
||||
}));
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 240 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 320 },
|
||||
{ id: 'severity', title: 'Severity', dataKey: 'severity', width: 120 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 220 },
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 180, format: 'datetime' },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 180, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
const guardrailsDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_guardrails',
|
||||
uniqueID: GUARDRAIL_PRIMARY_KEY,
|
||||
hotfields: [GUARDRAIL_PRIMARY_KEY],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete guardrail "${name}"?`)) return;
|
||||
busy = id;
|
||||
try {
|
||||
await api.guardrails.delete(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
function normalizeGuardrail(rowData: Record<string, unknown>): AgentGuardrail {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? ''),
|
||||
content: String(rowData.content ?? ''),
|
||||
severity: (typeof rowData.severity === 'string' ? rowData.severity : 'medium') as AgentGuardrail['severity'],
|
||||
tags: normalizeTags(rowData.tags),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
updated_at: String(rowData.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function toGuardrailForm(guardrail: AgentGuardrail): GuardrailForm {
|
||||
return {
|
||||
id: guardrail.id,
|
||||
name: guardrail.name,
|
||||
description: guardrail.description,
|
||||
content: guardrail.content,
|
||||
severity: guardrail.severity,
|
||||
tags: guardrail.tags.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGuardrailRecordForFormer(data: Record<string, unknown>): GuardrailForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: typeof data.name === 'string' ? data.name : '',
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
severity: typeof data.severity === 'string' ? data.severity : 'medium',
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGuardrailFromRow(rowData: Record<string, unknown>): Promise<AgentGuardrail> {
|
||||
const id = String(rowData[GUARDRAIL_PRIMARY_KEY] ?? '');
|
||||
return await guardrailOnAPICall('read', 'update', undefined, id) as AgentGuardrail;
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedGuardrail = rowData ? normalizeGuardrail(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '', content: '', severity: 'medium', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const guardrail = normalizeGuardrail(contextRow);
|
||||
selectedGuardrail = guardrail;
|
||||
editorValues = { id: guardrail.id, content: guardrail.content };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const guardrail = normalizeGuardrail(contextRow);
|
||||
formValues = toGuardrailForm(guardrail);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>
|
||||
) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}
|
||||
|
||||
function normalizeGuardrailForm(data: GuardrailForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim(),
|
||||
content: data.content,
|
||||
severity: data.severity,
|
||||
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGuardrailSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
selectedGuardrail = await loadGuardrailFromRow(contextRow);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleGuardrailEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
selectedGuardrail = await guardrailOnAPICall('read', 'update', undefined, editorValues.id) as AgentGuardrail;
|
||||
editorValues = { id: selectedGuardrail.id, content: selectedGuardrail.content };
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Guardrails</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{guardrails.length} guardrail{guardrails.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} guardrail{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => {
|
||||
formValues = { name: '', description: '', content: '', severity: 'medium', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
}}
|
||||
>New Guardrail</button>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="GuardrailsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={guardrailsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description', 'content', 'severity', 'tags']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if guardrails.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No guardrails registered.</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each guardrails as g}
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-semibold text-white">{g.name}</p>
|
||||
<span class={`rounded-full border px-2 py-0.5 text-xs font-medium ${severityColour[g.severity] ?? severityColour.medium}`}>
|
||||
{g.severity}
|
||||
</span>
|
||||
</div>
|
||||
{#if g.description}
|
||||
<p class="mt-1 text-sm text-slate-400">{g.description}</p>
|
||||
{/if}
|
||||
{#if g.tags?.length}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each g.tags as tag}
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-white">Guardrail Inspector</h3>
|
||||
{#if selectedGuardrail}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const guardrail = selectedGuardrail;
|
||||
if (!guardrail) return;
|
||||
editorValues = { id: guardrail.id, content: guardrail.content };
|
||||
editorOpened = true;
|
||||
}}
|
||||
>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedGuardrail}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a guardrail row to inspect its rule content.</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedGuardrail.name}</p>
|
||||
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${severityClasses[selectedGuardrail.severity] ?? severityClasses.medium}`}>
|
||||
{selectedGuardrail.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedGuardrail.description || '—'}</p>
|
||||
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedGuardrail.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedGuardrail.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Content</p>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-300">{selectedGuardrail.content}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#if selectedGuardrail.tags.length}
|
||||
{#each selectedGuardrail.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-slate-500">No tags</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
|
||||
onclick={() => remove(g.id, g.name)}
|
||||
disabled={busy === g.id}
|
||||
>Delete</button>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{g.content}</pre>
|
||||
</details>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="GuardrailsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Guardrail Content"
|
||||
uniqueKeyField={GUARDRAIL_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={guardrailOnAPICall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleGuardrailEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="guardrail.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="GuardrailsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Guardrail' : formRequest === 'update' ? 'Edit Guardrail' : 'Delete Guardrail'}
|
||||
uniqueKeyField={GUARDRAIL_PRIMARY_KEY}
|
||||
onAPICall={guardrailOnAPICall}
|
||||
afterGet={async (data) => normalizeGuardrailRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeGuardrailForm}
|
||||
afterSave={handleGuardrailSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
name="description"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="guardrail.md"
|
||||
value={state.values?.content ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Severity"
|
||||
name="severity"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.severity ?? 'medium'}
|
||||
options={['low', 'medium', 'high', 'critical']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, severity: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,18 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { GridlerFull, type GridlerColumn } from "@warkypublic/svelix";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
SwitchCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { Learning } from "../../types";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
type LearningForm = {
|
||||
id?: string;
|
||||
summary: string;
|
||||
details: string;
|
||||
category: string;
|
||||
area: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
confidence: string;
|
||||
action_required: boolean;
|
||||
source_type?: string;
|
||||
source_ref?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
const LEARNING_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedLearning = $state<Learning | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<LearningForm>({ summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; details: string }>({ details: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const learningOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/learnings'
|
||||
}));
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' },
|
||||
];
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
function toLearningForm(learning: Learning): LearningForm {
|
||||
return {
|
||||
id: learning.id,
|
||||
summary: learning.summary,
|
||||
details: learning.details,
|
||||
category: learning.category,
|
||||
area: learning.area,
|
||||
status: learning.status,
|
||||
priority: learning.priority,
|
||||
confidence: learning.confidence,
|
||||
action_required: learning.action_required,
|
||||
source_type: learning.source_type,
|
||||
source_ref: learning.source_ref,
|
||||
tags: learning.tags
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLearningRecordForFormer(data: Record<string, unknown>): LearningForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
summary: typeof data.summary === 'string' ? data.summary : '',
|
||||
details: typeof data.details === 'string' ? data.details : '',
|
||||
category: typeof data.category === 'string' ? data.category : '',
|
||||
area: typeof data.area === 'string' ? data.area : '',
|
||||
status: typeof data.status === 'string' ? data.status : 'active',
|
||||
priority: typeof data.priority === 'string' ? data.priority : 'medium',
|
||||
confidence: typeof data.confidence === 'string' ? data.confidence : 'medium',
|
||||
action_required: Boolean(data.action_required),
|
||||
source_type: typeof data.source_type === 'string' && data.source_type ? data.source_type : undefined,
|
||||
source_ref: typeof data.source_ref === 'string' && data.source_ref ? data.source_ref : undefined,
|
||||
tags: typeof data.tags === 'string' ? data.tags : undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLearningFromRow(rowData: Record<string, unknown>): Promise<Learning> {
|
||||
const id = String(rowData[LEARNING_PRIMARY_KEY] ?? '');
|
||||
return await learningOnAPICall('read', 'update', undefined, id) as Learning;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const learning = normalizeLearning(contextRow);
|
||||
selectedLearning = learning;
|
||||
editorValues = { id: learning.id, details: learning.details };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const learning = normalizeLearning(contextRow);
|
||||
formValues = toLearningForm(learning);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function normalizeLearningForm(data: LearningForm): Record<string, unknown> {
|
||||
return {
|
||||
summary: data.summary.trim(),
|
||||
details: data.details,
|
||||
category: data.category.trim(),
|
||||
area: data.area.trim(),
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
confidence: data.confidence,
|
||||
action_required: data.action_required,
|
||||
source_type: data.source_type?.trim() || undefined,
|
||||
source_ref: data.source_ref?.trim() || undefined,
|
||||
tags: data.tags?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleLearningSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
selectedLearning = await loadLearningFromRow(contextRow);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
async function handleLearningEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
selectedLearning = await learningOnAPICall('read', 'update', undefined, editorValues.id) as Learning;
|
||||
editorValues = { id: selectedLearning.id, details: selectedLearning.details };
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
const learningsDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "learnings",
|
||||
uniqueID: "id",
|
||||
uniqueID: LEARNING_PRIMARY_KEY,
|
||||
hotfields: [LEARNING_PRIMARY_KEY],
|
||||
sort: [{ column: "created_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
@@ -20,6 +165,7 @@
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
@@ -90,6 +236,12 @@
|
||||
selectedLearning = normalizeLearning(rowData);
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
@@ -120,29 +272,53 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => { formValues = { summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false }; formRequest = 'insert'; formOpened = true; }}>New Learning</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={learningsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["summary", "details", "category", "area", "status"]}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
/>
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="LearningsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={learningsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["summary", "details", "category", "area", "status"]}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Learning Inspector</h3>
|
||||
{#if selectedLearning}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const learning = selectedLearning;
|
||||
if (!learning) return;
|
||||
editorValues = { id: learning.id, details: learning.details };
|
||||
editorOpened = true;
|
||||
}}>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedLearning}
|
||||
@@ -217,3 +393,127 @@
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="LearningsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Learning Details"
|
||||
uniqueKeyField={LEARNING_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={learningOnAPICall}
|
||||
beforeSave={(data) => ({ details: data.details })}
|
||||
afterSave={handleLearningEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="learning.md"
|
||||
value={state.values?.details ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, details: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="LearningsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Learning' : formRequest === 'update' ? 'Edit Learning' : 'Delete Learning'}
|
||||
uniqueKeyField={LEARNING_PRIMARY_KEY}
|
||||
onAPICall={learningOnAPICall}
|
||||
afterGet={async (data) => normalizeLearningRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeLearningForm}
|
||||
afterSave={handleLearningSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Summary"
|
||||
name="summary"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.summary ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, summary: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="learning.md"
|
||||
value={state.values?.details ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, details: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Category"
|
||||
name="category"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.category ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, category: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Area"
|
||||
name="area"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.area ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, area: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Status"
|
||||
name="status"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.status ?? 'active'}
|
||||
options={['active', 'completed', 'pending', 'archived']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, status: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Priority"
|
||||
name="priority"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.priority ?? 'medium'}
|
||||
options={['low', 'medium', 'high', 'critical']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, priority: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Confidence"
|
||||
name="confidence"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.confidence ?? 'medium'}
|
||||
options={['low', 'medium', 'high']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, confidence: v })}
|
||||
/>
|
||||
<SwitchCtrl
|
||||
label="Action Required"
|
||||
name="action_required"
|
||||
disabled={state.request === 'delete'}
|
||||
checked={state.values?.action_required ?? false}
|
||||
onchange={(v) => state.setState('values', { ...state.values, action_required: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Source Type"
|
||||
name="source_type"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.source_type ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, source_type: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Source Ref"
|
||||
name="source_ref"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.source_ref ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, source_ref: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,18 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { GridlerFull, type GridlerColumn } from "@warkypublic/svelix";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { Plan } from "../../types";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
type PlanForm = {
|
||||
id?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
owner?: string;
|
||||
due_date?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
const PLAN_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedPlan = $state<Plan | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<PlanForm>({ title: '', description: '', status: 'draft', priority: 'medium' });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; description: string }>({ description: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const planOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/plans'
|
||||
}));
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' },
|
||||
];
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function loadPlanFromRow(rowData: Record<string, unknown>): Promise<Plan> {
|
||||
const data = await planOnAPICall('read', 'update', undefined, String(rowData[PLAN_PRIMARY_KEY] ?? '')) as Record<string, unknown>;
|
||||
return normalizePlan(data);
|
||||
}
|
||||
|
||||
function toPlanForm(plan: Plan): PlanForm {
|
||||
return {
|
||||
id: plan.id,
|
||||
title: plan.title,
|
||||
description: plan.description,
|
||||
status: plan.status,
|
||||
priority: plan.priority,
|
||||
owner: plan.owner,
|
||||
due_date: plan.due_date ? String(plan.due_date).slice(0, 10) : undefined,
|
||||
tags: plan.tags.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlanRecordForFormer(data: Record<string, unknown>): PlanForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
title: typeof data.title === 'string' ? data.title : '',
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
status: typeof data.status === 'string' ? data.status : 'draft',
|
||||
priority: typeof data.priority === 'string' ? data.priority : 'medium',
|
||||
owner: typeof data.owner === 'string' && data.owner ? data.owner : undefined,
|
||||
due_date: typeof data.due_date === 'string' && data.due_date ? data.due_date.slice(0, 10) : undefined,
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { title: '', description: '', status: 'draft', priority: 'medium' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const plan = normalizePlan(contextRow);
|
||||
selectedPlan = plan;
|
||||
editorValues = { id: plan.id, description: plan.description };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const plan = normalizePlan(contextRow);
|
||||
formValues = toPlanForm(plan);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function normalizePlanForm(data: PlanForm): Record<string, unknown> {
|
||||
return {
|
||||
title: data.title.trim(),
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
owner: data.owner?.trim() || undefined,
|
||||
due_date: data.due_date || undefined,
|
||||
tags: data.tags?.split(',').map((t) => t.trim()).filter(Boolean) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePlanSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
const data = await planOnAPICall('read', 'update', undefined, String(contextRow.id)) as Record<string, unknown>;
|
||||
selectedPlan = normalizePlan(data);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
async function handlePlanEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
const data = await planOnAPICall('read', 'update', undefined, String(editorValues.id)) as Record<string, unknown>;
|
||||
selectedPlan = normalizePlan(data);
|
||||
editorValues = { id: selectedPlan.id, description: selectedPlan.description };
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
const plansDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "plans",
|
||||
uniqueID: "id",
|
||||
uniqueID: PLAN_PRIMARY_KEY,
|
||||
hotfields: [PLAN_PRIMARY_KEY],
|
||||
sort: [{ column: "updated_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
@@ -20,6 +150,7 @@
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
@@ -66,6 +197,12 @@
|
||||
selectedPlan = rowData ? normalizePlan(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
@@ -112,28 +249,54 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => { formValues = { title: '', description: '', status: 'draft', priority: 'medium' }; formRequest = 'insert'; formOpened = true; }}>New Plan</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={plansDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["title", "description", "status", "priority", "owner"]}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
/>
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="PlansGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={plansDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["title", "description", "status", "priority", "owner"]}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="text-sm font-semibold text-white">Plan Inspector</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-white">Plan Inspector</h3>
|
||||
{#if selectedPlan}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const plan = selectedPlan;
|
||||
if (!plan) return;
|
||||
editorValues = { id: plan.id, description: plan.description };
|
||||
editorOpened = true;
|
||||
}}>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedPlan}
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
@@ -195,3 +358,99 @@
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="PlansEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Plan Description"
|
||||
uniqueKeyField={PLAN_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={planOnAPICall}
|
||||
beforeSave={(data) => ({ description: data.description })}
|
||||
afterSave={handlePlanEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="plan.md"
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="PlansFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Plan' : formRequest === 'update' ? 'Edit Plan' : 'Delete Plan'}
|
||||
uniqueKeyField={PLAN_PRIMARY_KEY}
|
||||
onAPICall={planOnAPICall}
|
||||
afterGet={async (data) => normalizePlanRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizePlanForm}
|
||||
afterSave={handlePlanSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Title"
|
||||
name="title"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.title ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, title: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="plan.md"
|
||||
value={state.values?.description ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Status"
|
||||
name="status"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.status ?? 'draft'}
|
||||
options={['draft', 'active', 'blocked', 'completed', 'cancelled', 'superseded']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, status: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Priority"
|
||||
name="priority"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.priority ?? 'medium'}
|
||||
options={['low', 'medium', 'high', 'critical']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, priority: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Owner"
|
||||
name="owner"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.owner ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, owner: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Due Date"
|
||||
name="due_date"
|
||||
type="date"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.due_date ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, due_date: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,27 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { GridlerFull, TextInputCtrl, type GridlerColumn } from '@warkypublic/svelix';
|
||||
import { api } from '../../api';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import type { ProjectSummary } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
type ProjectForm = {
|
||||
id?: string;
|
||||
guid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PROJECT_PRIMARY_KEY = 'id';
|
||||
|
||||
let projects = $state<ProjectSummary[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let creating = $state(false);
|
||||
let showCreate = $state(false);
|
||||
let newName = $state('');
|
||||
let newDesc = $state('');
|
||||
let createError = $state('');
|
||||
let selectedProject = $state<ProjectSummary | null>(null);
|
||||
let selectedProjectRecordID = $state<string | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<ProjectForm>({ name: '', description: '' });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; description: string }>({ description: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const projectOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/projects'
|
||||
}));
|
||||
|
||||
const projectDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'projects',
|
||||
uniqueID: 'id',
|
||||
hotfields: ['id', 'guid'],
|
||||
uniqueID: PROJECT_PRIMARY_KEY,
|
||||
hotfields: [PROJECT_PRIMARY_KEY, 'guid'],
|
||||
computedColumns: [
|
||||
{
|
||||
name: 'thought_count',
|
||||
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
|
||||
}
|
||||
],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
@@ -30,6 +59,7 @@
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
computedColumns: { name: string; expression: string }[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
@@ -39,44 +69,30 @@
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 200, format: 'datetime' }
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.projects.list();
|
||||
if (selectedProject) {
|
||||
selectedProject = projects.find((project) => project.id === selectedProject?.id) ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load projects';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
async function create() {
|
||||
if (!newName.trim()) return;
|
||||
creating = true;
|
||||
createError = '';
|
||||
try {
|
||||
await api.projects.create(newName.trim(), newDesc.trim());
|
||||
newName = '';
|
||||
newDesc = '';
|
||||
showCreate = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : 'Failed to create project';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
function toProjectForm(rowData: Record<string, unknown>): ProjectForm {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
guid: String(rowData.guid ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function onProjectRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) {
|
||||
selectedProject = null;
|
||||
selectedProjectRecordID = null;
|
||||
return;
|
||||
}
|
||||
const id = String(rowData.guid ?? rowData.id ?? '');
|
||||
selectedProjectRecordID = String(rowData[PROJECT_PRIMARY_KEY] ?? '');
|
||||
const id = String(rowData.guid ?? rowData[PROJECT_PRIMARY_KEY] ?? '');
|
||||
selectedProject = {
|
||||
id,
|
||||
name: String(rowData.name ?? ''),
|
||||
@@ -87,88 +103,186 @@
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
onProjectRowClick(0, contextRow);
|
||||
editorValues = {
|
||||
id: String(contextRow[PROJECT_PRIMARY_KEY] ?? ''),
|
||||
description: String(contextRow.description ?? '')
|
||||
};
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
formValues = toProjectForm(contextRow);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function normalizeProjectForm(data: ProjectForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim()
|
||||
};
|
||||
}
|
||||
|
||||
async function handleProjectSaved() {
|
||||
formOpened = false;
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (selectedProjectRecordID && editorValues.id === selectedProjectRecordID && selectedProject) {
|
||||
selectedProject.description = editorValues.description;
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Projects</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{projects.length} project{projects.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} project{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="inline-flex items-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
<button
|
||||
class="inline-flex items-center rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||
onclick={() => { showCreate = !showCreate; }}
|
||||
onclick={() => {
|
||||
formValues = { name: '', description: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
}}
|
||||
>New project</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5">
|
||||
<h3 class="text-sm font-semibold text-white">Create project</h3>
|
||||
<div class="mt-3 space-y-3">
|
||||
<TextInputCtrl
|
||||
label="Project name"
|
||||
placeholder="Name"
|
||||
required
|
||||
bind:value={newName}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="ProjectsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={projectDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description']}
|
||||
{menuItems}
|
||||
onRowClick={onProjectRowClick}
|
||||
onGridEvent={(type, _item, _column, _coords, detail) => {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
placeholder="Description (optional)"
|
||||
bind:value={newDesc}
|
||||
/>
|
||||
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20 disabled:opacity-50"
|
||||
onclick={create}
|
||||
disabled={creating || !newName.trim()}
|
||||
>{creating ? 'Creating…' : 'Create'}</button>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/10"
|
||||
onclick={() => { showCreate = false; createError = ''; }}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">
|
||||
No projects yet.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={projectDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description']}
|
||||
onRowClick={onProjectRowClick}
|
||||
/>
|
||||
</div>
|
||||
{#if selectedProject}
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
{#if selectedProject}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="text-sm font-semibold text-white">Selected project</h3>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Selected project</h3>
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const project = selectedProject;
|
||||
if (!project) return;
|
||||
editorValues = {
|
||||
id: selectedProjectRecordID ?? undefined,
|
||||
description: project.description
|
||||
};
|
||||
editorOpened = true;
|
||||
}}
|
||||
>Edit Content</button>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-300"><strong class="text-slate-100">{selectedProject.name}</strong> · {selectedProject.thought_count} thoughts</p>
|
||||
<p class="mt-1 text-sm text-slate-400">{selectedProject.description || 'No description.'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="ProjectsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Project Description"
|
||||
uniqueKeyField={PROJECT_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={projectOnAPICall}
|
||||
beforeSave={(data) => ({ description: data.description.trim() })}
|
||||
afterSave={handleEditorSaved}
|
||||
onClose={() => {
|
||||
editorOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="project.md"
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="ProjectsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Project' : formRequest === 'update' ? 'Edit Project' : 'Delete Project'}
|
||||
uniqueKeyField={PROJECT_PRIMARY_KEY}
|
||||
onAPICall={projectOnAPICall}
|
||||
beforeSave={normalizeProjectForm}
|
||||
afterSave={handleProjectSaved}
|
||||
onClose={() => {
|
||||
formOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<TextInputCtrl
|
||||
label="Project name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="project.md"
|
||||
value={state.values?.description ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
44
ui/src/components/shared/ContentEditorField.svelte
Normal file
44
ui/src/components/shared/ContentEditorField.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { ContentEditor } from '@warkypublic/svelix';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
filename: string;
|
||||
disabled?: boolean;
|
||||
onchange: (text: string) => void;
|
||||
}
|
||||
|
||||
let { value, filename, disabled = false, onchange }: Props = $props();
|
||||
|
||||
let currentBlob = $state<Blob | undefined>(undefined);
|
||||
|
||||
const initialBlob = $derived(new Blob([value ?? ''], { type: 'text/plain' }));
|
||||
|
||||
$effect(() => {
|
||||
currentBlob = undefined;
|
||||
});
|
||||
|
||||
async function handleBlobChange(blob?: Blob) {
|
||||
currentBlob = blob;
|
||||
if (!blob) {
|
||||
onchange('');
|
||||
return;
|
||||
}
|
||||
onchange(await blob.text());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-slate-950/60 overflow-hidden">
|
||||
<div class="border-b border-white/10 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
{filename}
|
||||
</div>
|
||||
<div class:opacity-60={disabled} class:pointer-events-none={disabled}>
|
||||
<ContentEditor
|
||||
value={currentBlob ?? initialBlob}
|
||||
{filename}
|
||||
colorScheme="dark"
|
||||
hideHeader={true}
|
||||
onChange={(blob) => { void handleBlobChange(blob); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
94
ui/src/components/shared/ContentEditorModal.svelte
Normal file
94
ui/src/components/shared/ContentEditorModal.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { ContentEditor } from '@warkypublic/svelix';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
filename: string;
|
||||
onSave: (text: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, title, content, filename, onSave, onClose }: Props = $props();
|
||||
|
||||
let saveError = $state('');
|
||||
let saving = $state(false);
|
||||
let fullscreen = $state(false);
|
||||
let currentBlob = $state<Blob | undefined>(undefined);
|
||||
|
||||
const initialBlob = $derived(open ? new Blob([content], { type: 'text/plain' }) : undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (open) currentBlob = undefined;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const blob = currentBlob ?? initialBlob;
|
||||
if (!blob) return;
|
||||
saveError = '';
|
||||
saving = true;
|
||||
try {
|
||||
const text = await blob.text();
|
||||
await onSave(text);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
saveError = e instanceof Error ? e.message : 'Save failed';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { if (fullscreen) { fullscreen = false; } else { onClose(); } }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-8" class:p-0={fullscreen}>
|
||||
<div
|
||||
class="flex flex-col w-full max-w-5xl bg-slate-900 transition-all"
|
||||
class:max-w-5xl={!fullscreen}
|
||||
style={fullscreen ? 'height: 100vh; border-radius: 0; border: none;' : 'height: 85vh; border-radius: 1rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.1);'}
|
||||
>
|
||||
<div class="flex-none flex items-center justify-between px-5 py-3 border-b border-white/10 bg-slate-800">
|
||||
<h2 class="text-sm font-semibold text-white">{title}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if saveError}
|
||||
<p class="text-xs text-rose-300">{saveError}</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { fullscreen = !fullscreen; }}
|
||||
class="text-xs text-slate-400 hover:text-white"
|
||||
title={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>{fullscreen ? '⊡' : '⛶'}</button>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-xs text-slate-400 hover:text-white"
|
||||
>Cancel</button>
|
||||
<button
|
||||
onclick={save}
|
||||
disabled={saving}
|
||||
class="rounded-lg bg-cyan-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-cyan-500 disabled:opacity-50"
|
||||
>{saving ? 'Saving…' : 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden bg-slate-900">
|
||||
<ContentEditor
|
||||
value={initialBlob}
|
||||
{filename}
|
||||
colorScheme="dark"
|
||||
hideHeader={true}
|
||||
onChange={(v) => { currentBlob = v; }}
|
||||
onSave={save}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
38
ui/src/components/shared/FormerShell.svelte
Normal file
38
ui/src/components/shared/FormerShell.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { FormerDrawer } from '@warkypublic/svelix';
|
||||
import type { FormerProps, FormRequestType } from '@warkypublic/svelix';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props extends FormerProps<any> {
|
||||
title?: string;
|
||||
width?: string;
|
||||
children?: Snippet<[any]>;
|
||||
}
|
||||
|
||||
let {
|
||||
title = 'Form',
|
||||
opened = $bindable(false),
|
||||
values = $bindable<any>(undefined),
|
||||
request = $bindable<FormRequestType>('insert'),
|
||||
layout = { buttonArea: 'bottom' },
|
||||
width = '36rem',
|
||||
children: formContent,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<FormerDrawer
|
||||
bind:opened
|
||||
bind:values
|
||||
bind:request
|
||||
{title}
|
||||
{layout}
|
||||
{width}
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-6">
|
||||
{@render formContent?.(state)}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerDrawer>
|
||||
@@ -1,91 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import type { AgentSkill } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
let skills = $state<AgentSkill[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let busy = $state<string | null>(null);
|
||||
type SkillForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
skills = await api.skills.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load skills';
|
||||
} finally {
|
||||
loading = false;
|
||||
const SKILL_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedSkill = $state<AgentSkill | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<SkillForm>({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
tags: ''
|
||||
});
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const skillOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_skills'
|
||||
}));
|
||||
|
||||
const skillsDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_skills',
|
||||
uniqueID: SKILL_PRIMARY_KEY,
|
||||
hotfields: [SKILL_PRIMARY_KEY],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 240 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 300 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 220 },
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 180, format: 'datetime' },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 180, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete skill "${name}"?`)) return;
|
||||
busy = id;
|
||||
try {
|
||||
await api.skills.delete(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
function normalizeSkill(rowData: Record<string, unknown>): AgentSkill {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? ''),
|
||||
content: String(rowData.content ?? ''),
|
||||
tags: normalizeTags(rowData.tags),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
updated_at: String(rowData.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function toSkillForm(skill: AgentSkill): SkillForm {
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
tags: skill.tags.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillRecordForFormer(data: Record<string, unknown>): SkillForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: typeof data.name === 'string' ? data.name : '',
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedSkill = rowData ? normalizeSkill(rowData) : null;
|
||||
}
|
||||
|
||||
async function loadSkillFromRow(rowData: Record<string, unknown>): Promise<AgentSkill> {
|
||||
const id = String(rowData[SKILL_PRIMARY_KEY] ?? '');
|
||||
const data = await skillOnAPICall('read', 'update', undefined, id) as Record<string, unknown>;
|
||||
return normalizeSkill(data);
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '', content: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const skill = normalizeSkill(contextRow);
|
||||
selectedSkill = skill;
|
||||
editorValues = { id: skill.id, content: skill.content };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const skill = normalizeSkill(contextRow);
|
||||
formValues = toSkillForm(skill);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>
|
||||
) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}
|
||||
|
||||
function normalizeSkillForm(data: SkillForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim(),
|
||||
content: data.content,
|
||||
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSkillSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.[SKILL_PRIMARY_KEY]) {
|
||||
const data = await skillOnAPICall('read', 'update', undefined, String(contextRow[SKILL_PRIMARY_KEY])) as Record<string, unknown>;
|
||||
selectedSkill = normalizeSkill(data);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
const data = await skillOnAPICall('read', 'update', undefined, String(editorValues.id)) as Record<string, unknown>;
|
||||
const refreshed = normalizeSkill(data);
|
||||
selectedSkill = refreshed;
|
||||
editorValues = { id: refreshed.id, content: refreshed.content };
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Skills</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{skills.length} skill{skills.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} skill{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||
onclick={() => {
|
||||
formValues = { name: '', description: '', content: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
}}
|
||||
>New Skill</button>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if skills.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No skills registered.</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each skills as skill}
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-white">{skill.name}</p>
|
||||
{#if skill.description}
|
||||
<p class="mt-1 text-sm text-slate-400">{skill.description}</p>
|
||||
{/if}
|
||||
{#if skill.tags?.length}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each skill.tags as tag}
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
|
||||
onclick={() => remove(skill.id, skill.name)}
|
||||
disabled={busy === skill.id}
|
||||
>Delete</button>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{skill.content}</pre>
|
||||
</details>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="SkillsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={skillsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description', 'content', 'tags']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Skill Inspector</h3>
|
||||
{#if selectedSkill}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
if (!selectedSkill) return;
|
||||
editorValues = { id: selectedSkill.id, content: selectedSkill.content };
|
||||
editorOpened = true;
|
||||
}}
|
||||
>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedSkill}
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
Select a skill row to inspect details.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedSkill.name}</p>
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedSkill.description || '—'}</p>
|
||||
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedSkill.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedSkill.updated_at)}</p>
|
||||
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#if selectedSkill.tags.length}
|
||||
{#each selectedSkill.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-slate-500">No tags</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Content</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedSkill.content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="SkillsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Skill Content"
|
||||
uniqueKeyField={SKILL_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={skillOnAPICall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleEditorSaved}
|
||||
onClose={() => {
|
||||
editorOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="skill.md"
|
||||
value={state.values?.content ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="SkillsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Skill' : formRequest === 'update' ? 'Edit Skill' : 'Delete Skill'}
|
||||
uniqueKeyField={SKILL_PRIMARY_KEY}
|
||||
onAPICall={skillOnAPICall}
|
||||
afterGet={async (data) => normalizeSkillRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeSkillForm}
|
||||
afterSave={handleSkillSaved}
|
||||
onClose={() => {
|
||||
formOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
name="description"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="skill.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
type GridColumnFilters,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "../../api";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { StoredFile, Thought, ThoughtLink } from "../../types";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
type ThoughtForm = { id?: string; content: string; project_id?: string };
|
||||
|
||||
const THOUGHT_PRIMARY_KEY = 'id';
|
||||
|
||||
let includeArchived = $state(false);
|
||||
let actionBusy = $state<string | null>(null);
|
||||
@@ -16,14 +27,107 @@
|
||||
let selectedThought = $state<Thought | null>(null);
|
||||
let relatedLinks = $state<ThoughtLink[]>([]);
|
||||
let relatedFiles = $state<StoredFile[]>([]);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<ThoughtForm>({ content: '' });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
let projectOptions = $state<{ label: string; value: string }[]>([]);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const thoughtOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/thoughts'
|
||||
}));
|
||||
|
||||
onMount(async () => {
|
||||
const projects = await api.projects.list();
|
||||
projectOptions = projects.map((p) => ({ label: p.name, value: p.id }));
|
||||
});
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' },
|
||||
];
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
function normalizeThoughtRecordForFormer(data: Record<string, unknown>): ThoughtForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
project_id: typeof data.project_id === 'string' && data.project_id ? data.project_id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadThoughtFromRow(rowData: Record<string, unknown>): Promise<Thought> {
|
||||
const id = String(rowData[THOUGHT_PRIMARY_KEY] ?? '');
|
||||
return await thoughtOnAPICall('read', 'update', undefined, id) as Thought;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { content: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const thought = normalizeThought(contextRow);
|
||||
selectedThought = thought;
|
||||
editorValues = { id: thought.id, content: thought.content };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const thought = normalizeThought(contextRow);
|
||||
formValues = {
|
||||
id: thought.id,
|
||||
content: thought.content,
|
||||
project_id: thought.project_id
|
||||
};
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function normalizeThoughtForm(data: ThoughtForm): Record<string, unknown> {
|
||||
return {
|
||||
content: data.content,
|
||||
project_id: data.project_id || undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function handleThoughtSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
const thought = await loadThoughtFromRow(contextRow);
|
||||
await inspectThought(thought);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
async function handleThoughtEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
const thought = await thoughtOnAPICall('read', 'update', undefined, editorValues.id) as Thought;
|
||||
editorValues = { id: thought.id, content: thought.content };
|
||||
await inspectThought(thought);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
let gridTotal = $state<number | null>(null);
|
||||
const thoughtsDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "thoughts",
|
||||
uniqueID: "id",
|
||||
hotfields: ["guid", "metadata", "project_id", "archived_at"],
|
||||
uniqueID: THOUGHT_PRIMARY_KEY,
|
||||
hotfields: [THOUGHT_PRIMARY_KEY, "guid", "metadata", "project_id", "archived_at"],
|
||||
sort: [{ column: "created_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
@@ -177,6 +281,12 @@
|
||||
void inspectThought(normalizeThought(rowData));
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
@@ -220,6 +330,10 @@
|
||||
/>
|
||||
Archived
|
||||
</label>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => { formValues = { content: '' }; formRequest = 'insert'; formOpened = true; }}>New Thought</button
|
||||
>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => {
|
||||
@@ -237,21 +351,29 @@
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={thoughtsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["content"]}
|
||||
filters={baseFilters}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
/>
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="ThoughtsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={thoughtsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["content"]}
|
||||
filters={baseFilters}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
@@ -259,6 +381,15 @@
|
||||
<h3 class="text-sm font-semibold text-white">Thought Inspector</h3>
|
||||
{#if selectedThought}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const thought = selectedThought;
|
||||
if (!thought) return;
|
||||
editorValues = { id: thought.id, content: thought.content };
|
||||
editorOpened = true;
|
||||
}}>Edit Content</button
|
||||
>
|
||||
{#if !isSelectedArchived()}
|
||||
<button
|
||||
class="text-xs text-slate-300 hover:text-white"
|
||||
@@ -387,3 +518,60 @@
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="ThoughtsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Thought"
|
||||
uniqueKeyField={THOUGHT_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={thoughtOnAPICall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleThoughtEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="thought.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="ThoughtsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Thought' : formRequest === 'update' ? 'Edit Thought' : 'Delete Thought'}
|
||||
uniqueKeyField={THOUGHT_PRIMARY_KEY}
|
||||
onAPICall={thoughtOnAPICall}
|
||||
afterGet={async (data) => normalizeThoughtRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeThoughtForm}
|
||||
afterSave={handleThoughtSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<ContentEditorField
|
||||
filename="thought.md"
|
||||
value={state.values?.content ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Project"
|
||||
name="project_id"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.project_id ?? ''}
|
||||
options={[{ label: '— None —', value: '' }, ...projectOptions]}
|
||||
onchange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
Reference in New Issue
Block a user