feat(core): add Data Stores management page at /core/data_stores
Full CRUD for all data_store records: search by code, account_id, for_type, for_id; create/edit/delete via modal with type-aware content editor (CodeMirror / TipTap / textarea). Wired into core nav and dashboard. for_id shown read-only on edit (DB integer FK constraint). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { goto } from '$app/navigation';
|
||||
import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores';
|
||||
import {
|
||||
Building,
|
||||
Database,
|
||||
Globe,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
@@ -71,6 +72,9 @@ onMount(() => {
|
||||
<a href="/core/lookups" class="btn btn-sm preset-tonal-surface">
|
||||
<List size={14} class="mr-1" /> Lookups
|
||||
</a>
|
||||
<a href="/core/data_stores" class="btn btn-sm preset-tonal-surface">
|
||||
<Database size={14} class="mr-1" /> Data Stores
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<section class="main_content grow px-1 pb-28 md:px-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Building,
|
||||
Database,
|
||||
Globe,
|
||||
History,
|
||||
Landmark,
|
||||
@@ -213,6 +214,28 @@ $effect(() => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Data Stores Card -->
|
||||
<div
|
||||
class="card preset-tonal-surface group border-surface-500/10 flex flex-col justify-between space-y-4 border p-6 shadow-lg transition-all hover:brightness-110">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="bg-surface-500/20 rounded-lg p-2 transition-transform group-hover:scale-110">
|
||||
<Database size={24} />
|
||||
</div>
|
||||
<h3 class="h4 font-black">Data Stores</h3>
|
||||
</div>
|
||||
<p class="text-sm leading-relaxed opacity-80">
|
||||
Manage all content and configuration data stores across accounts.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
class="btn preset-filled-surface mt-4 w-full font-bold shadow-md"
|
||||
href="/core/data_stores">
|
||||
Manage
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Lookups Card -->
|
||||
<div
|
||||
class="card preset-tonal-surface group border-surface-500/10 flex flex-col justify-between space-y-4 border p-6 shadow-lg transition-all hover:brightness-110">
|
||||
|
||||
735
src/routes/core/data_stores/+page.svelte
Normal file
735
src/routes/core/data_stores/+page.svelte
Normal file
@@ -0,0 +1,735 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Modal } from 'flowbite-svelte';
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code,
|
||||
Database,
|
||||
Eye,
|
||||
Filter,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Trash2,
|
||||
X
|
||||
} from '@lucide/svelte';
|
||||
|
||||
import { api } from '$lib/api/api';
|
||||
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
||||
import { db_core } from '$lib/ae_core/db_core';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import type { ae_DataStore } from '$lib/types/ae_types';
|
||||
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
|
||||
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (!$ae_loc.manager_access) {
|
||||
goto('/core');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Filter state ──────────────────────────────────────────────────────────────
|
||||
let qry_code = $state('');
|
||||
let qry_account_id = $state('');
|
||||
let qry_for_type = $state('');
|
||||
let qry_for_id = $state('');
|
||||
let qry_enabled = $state<'all' | 'enabled' | 'not_enabled'>('all');
|
||||
let page_limit = $state(50);
|
||||
let page_offset = $state(0);
|
||||
|
||||
// ── Results ───────────────────────────────────────────────────────────────────
|
||||
let results: ae_DataStore[] = $state([]);
|
||||
let loading = $state(false);
|
||||
let searched = $state(false);
|
||||
|
||||
// ── Edit modal ────────────────────────────────────────────────────────────────
|
||||
let show_edit = $state(false);
|
||||
let editing_obj: ae_DataStore | null = $state(null);
|
||||
let is_new = $state(false);
|
||||
|
||||
let draft_code = $state('');
|
||||
let draft_name = $state('');
|
||||
let draft_type = $state('text');
|
||||
let draft_account_id = $state('');
|
||||
let draft_for_type = $state('');
|
||||
let draft_for_id = $state('');
|
||||
let draft_value = $state('');
|
||||
let draft_enable = $state(true);
|
||||
let draft_hide = $state(false);
|
||||
let draft_priority = $state(false);
|
||||
let draft_sort = $state('');
|
||||
let draft_group = $state('');
|
||||
let draft_notes = $state('');
|
||||
let html_edit_mode = $state<'source' | 'visual'>('source');
|
||||
let submit_status = $state<'idle' | 'processing' | 'saved' | 'error'>('idle');
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────────
|
||||
async function do_search(reset = true) {
|
||||
if (reset) page_offset = 0;
|
||||
loading = true;
|
||||
|
||||
const search_query: any = { and: [] };
|
||||
|
||||
if (qry_code.trim()) {
|
||||
search_query.and.push({ field: 'code', op: 'like', value: qry_code.trim() });
|
||||
}
|
||||
if (qry_account_id.trim() === 'global') {
|
||||
search_query.and.push({ field: 'account_id', op: 'is', value: null });
|
||||
} else if (qry_account_id.trim()) {
|
||||
search_query.and.push({ field: 'account_id_random', op: 'eq', value: qry_account_id.trim() });
|
||||
}
|
||||
if (qry_for_type.trim()) {
|
||||
search_query.and.push({ field: 'for_type', op: 'like', value: qry_for_type.trim() });
|
||||
}
|
||||
if (qry_for_id.trim()) {
|
||||
search_query.and.push({ field: 'for_id_random', op: 'eq', value: qry_for_id.trim() });
|
||||
}
|
||||
if (qry_enabled === 'enabled') {
|
||||
search_query.and.push({ field: 'enable', op: 'eq', value: true });
|
||||
} else if (qry_enabled === 'not_enabled') {
|
||||
search_query.and.push({ field: 'enable', op: 'eq', value: false });
|
||||
}
|
||||
|
||||
const result_li = await api.search_ae_obj({
|
||||
api_cfg: $ae_api,
|
||||
obj_type: 'data_store',
|
||||
search_query,
|
||||
order_by_li: [{ code: 'ASC' }, { updated_on: 'DESC' }],
|
||||
limit: page_limit,
|
||||
offset: page_offset,
|
||||
log_lvl: 0
|
||||
});
|
||||
|
||||
results = result_li ?? [];
|
||||
searched = true;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function clear_filters() {
|
||||
qry_code = '';
|
||||
qry_account_id = '';
|
||||
qry_for_type = '';
|
||||
qry_for_id = '';
|
||||
qry_enabled = 'all';
|
||||
results = [];
|
||||
searched = false;
|
||||
}
|
||||
|
||||
// ── Open edit ─────────────────────────────────────────────────────────────────
|
||||
function open_edit(obj: ae_DataStore) {
|
||||
editing_obj = obj;
|
||||
is_new = false;
|
||||
draft_code = obj.code ?? '';
|
||||
draft_name = obj.name ?? '';
|
||||
draft_type = obj.type ?? 'text';
|
||||
draft_account_id = obj.account_id ?? '';
|
||||
draft_for_type = obj.for_type ?? '';
|
||||
draft_for_id = obj.for_id ?? '';
|
||||
draft_enable = obj.enable !== false;
|
||||
draft_hide = !!obj.hide;
|
||||
draft_priority = !!obj.priority;
|
||||
draft_sort = String(obj.sort ?? '');
|
||||
draft_group = obj.group ?? '';
|
||||
draft_notes = obj.notes ?? '';
|
||||
draft_value =
|
||||
obj.type === 'json'
|
||||
? typeof obj.json === 'string'
|
||||
? obj.json
|
||||
: JSON.stringify(obj.json ?? '', null, 2)
|
||||
: (obj.text ?? obj.html ?? '');
|
||||
html_edit_mode = 'source';
|
||||
submit_status = 'idle';
|
||||
show_edit = true;
|
||||
}
|
||||
|
||||
function open_new() {
|
||||
editing_obj = null;
|
||||
is_new = true;
|
||||
draft_code = '';
|
||||
draft_name = '';
|
||||
draft_type = 'text';
|
||||
draft_account_id = $ae_loc.account_id ?? '';
|
||||
draft_for_type = '';
|
||||
draft_for_id = '';
|
||||
draft_value = '';
|
||||
draft_enable = true;
|
||||
draft_hide = false;
|
||||
draft_priority = false;
|
||||
draft_sort = '';
|
||||
draft_group = '';
|
||||
draft_notes = '';
|
||||
html_edit_mode = 'source';
|
||||
submit_status = 'idle';
|
||||
show_edit = true;
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────────
|
||||
async function handle_save() {
|
||||
submit_status = 'processing';
|
||||
const api_cfg = untrack(() => $ae_api);
|
||||
|
||||
const fields: Record<string, any> = {
|
||||
code: draft_code.trim(),
|
||||
name: draft_name.trim(),
|
||||
type: draft_type,
|
||||
account_id: draft_account_id.trim() || null,
|
||||
for_type: draft_for_type.trim() || null,
|
||||
enable: draft_enable,
|
||||
hide: draft_hide,
|
||||
priority: draft_priority,
|
||||
sort: draft_sort.trim() || null,
|
||||
group: draft_group.trim() || null,
|
||||
notes: draft_notes.trim() || null
|
||||
};
|
||||
|
||||
// for_id can only be meaningfully set on create — backend stores it as an integer FK
|
||||
// and doesn't accept the random string on PATCH.
|
||||
if (is_new && draft_for_id.trim()) {
|
||||
fields.for_id = draft_for_id.trim();
|
||||
}
|
||||
|
||||
if (draft_type === 'json') {
|
||||
try { fields.json = JSON.parse(draft_value); }
|
||||
catch { fields.json = draft_value; }
|
||||
} else {
|
||||
fields.text = draft_value;
|
||||
}
|
||||
|
||||
let result: any;
|
||||
if (is_new) {
|
||||
result = await api.create_ae_obj({ api_cfg, obj_type: 'data_store', fields });
|
||||
} else {
|
||||
result = await api.update_ae_obj({
|
||||
api_cfg,
|
||||
obj_type: 'data_store',
|
||||
obj_id: editing_obj!.id,
|
||||
fields
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const ds_id = result.data_store_id || result.id;
|
||||
if (ds_id) {
|
||||
const text_val = result.text ?? '';
|
||||
await db_core.data_store.put({
|
||||
...result,
|
||||
id: ds_id,
|
||||
data_store_id: ds_id,
|
||||
updated_on: result.updated_on ?? new Date().toISOString(),
|
||||
text: text_val,
|
||||
html: text_val,
|
||||
json: result.json ?? null
|
||||
});
|
||||
}
|
||||
submit_status = 'saved';
|
||||
setTimeout(() => {
|
||||
show_edit = false;
|
||||
do_search(false);
|
||||
}, 600);
|
||||
} else {
|
||||
submit_status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
async function handle_delete() {
|
||||
if (
|
||||
!editing_obj?.id ||
|
||||
!confirm(`Delete data store "${editing_obj.code}"?\n\nThis cannot be undone.`)
|
||||
)
|
||||
return;
|
||||
const api_cfg = untrack(() => $ae_api);
|
||||
const result = await api.delete_ae_obj({
|
||||
api_cfg,
|
||||
obj_type: 'data_store',
|
||||
obj_id: editing_obj.id,
|
||||
method: 'delete'
|
||||
});
|
||||
if (result) {
|
||||
await db_core.data_store.delete(editing_obj.id);
|
||||
show_edit = false;
|
||||
do_search(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function type_badge(type: string | null | undefined) {
|
||||
switch (type) {
|
||||
case 'json': return 'preset-tonal-warning';
|
||||
case 'html': return 'preset-tonal-primary';
|
||||
case 'sql': return 'preset-tonal-error';
|
||||
case 'md': return 'preset-tonal-success';
|
||||
default: return 'preset-tonal-surface';
|
||||
}
|
||||
}
|
||||
|
||||
function fmt_date(val: string | Date | null | undefined) {
|
||||
if (!val) return '—';
|
||||
return ae_util.iso_datetime_formatter(val as any, 'datetime_12_short');
|
||||
}
|
||||
|
||||
function content_preview(ds: ae_DataStore): string {
|
||||
if (ds.type === 'json') return ds.json ? JSON.stringify(ds.json).slice(0, 60) : '';
|
||||
return (ds.text ?? ds.html ?? '').slice(0, 60).replace(/\s+/g, ' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto space-y-6 p-4">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<header class="bg-surface-50-900-token border-surface-500/10 flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 shadow-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary-500/10 rounded-lg p-2">
|
||||
<Database size={24} class="text-primary-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="h2 font-black tracking-tight">Data Stores</h1>
|
||||
<p class="text-xs font-bold tracking-widest uppercase opacity-50">Content & Configuration Storage</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={open_new}
|
||||
class="btn preset-filled-primary font-bold shadow-lg"
|
||||
class:hidden={!$ae_loc.manager_access}>
|
||||
<Plus size={16} class="mr-2" /> New Data Store
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- ── Filter bar ──────────────────────────────────────────────────────── -->
|
||||
<div class="card preset-tonal-surface border-surface-500/10 space-y-3 border p-4 shadow-xl">
|
||||
<div class="flex items-center gap-2 text-[10px] font-bold tracking-widest uppercase opacity-50">
|
||||
<Filter size={11} /> Filters
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">Code</label>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={qry_code}
|
||||
placeholder="event__pres_mgmt__*"
|
||||
class="input input-sm w-full font-mono text-xs"
|
||||
onkeydown={(e) => e.key === 'Enter' && do_search()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">
|
||||
Account ID
|
||||
<span class="font-normal opacity-60">— type <code>global</code> for null</span>
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={qry_account_id}
|
||||
placeholder="random ID or 'global'"
|
||||
class="input input-sm w-full font-mono text-xs"
|
||||
onkeydown={(e) => e.key === 'Enter' && do_search()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">For Type</label>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={qry_for_type}
|
||||
placeholder="event, event_session, person…"
|
||||
class="input input-sm w-full font-mono text-xs"
|
||||
onkeydown={(e) => e.key === 'Enter' && do_search()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">For ID</label>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={qry_for_id}
|
||||
placeholder="random string ID"
|
||||
class="input input-sm w-full font-mono text-xs"
|
||||
onkeydown={(e) => e.key === 'Enter' && do_search()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-1">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">Status</label>
|
||||
<select class="select select-sm text-xs" bind:value={qry_enabled}>
|
||||
<option value="all">All</option>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="not_enabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">Per page</label>
|
||||
<select class="select select-sm text-xs" bind:value={page_limit}>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-1 items-end justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={clear_filters}
|
||||
class="btn btn-sm preset-tonal-surface text-xs"
|
||||
title="Clear all filters">
|
||||
<X size={13} class="mr-1" /> Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => do_search()}
|
||||
class="btn preset-filled-primary"
|
||||
disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle size={16} class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Search size={16} class="mr-2" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Results ─────────────────────────────────────────────────────────── -->
|
||||
{#if results.length > 0}
|
||||
<div class="card preset-tonal-surface border-surface-500/10 border shadow-xl">
|
||||
|
||||
<!-- table header row -->
|
||||
<div class="border-surface-500/20 flex items-center justify-between border-b px-4 py-2">
|
||||
<span class="text-xs font-bold opacity-60">
|
||||
{results.length} record{results.length !== 1 ? 's' : ''}
|
||||
{#if results.length === page_limit}
|
||||
<span class="opacity-50"> (may be more — increase per-page or paginate)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if page_offset > 0}
|
||||
<button type="button" class="btn btn-sm preset-tonal-surface"
|
||||
onclick={() => { page_offset = Math.max(0, page_offset - page_limit); do_search(false); }}>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if results.length === page_limit}
|
||||
<button type="button" class="btn btn-sm preset-tonal-surface"
|
||||
onclick={() => { page_offset += page_limit; do_search(false); }}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" onclick={() => do_search(false)} class="btn btn-sm preset-tonal-surface" title="Refresh">
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-surface-500/20 border-b text-left text-[10px] font-bold uppercase tracking-wider opacity-50">
|
||||
<th class="px-3 py-2">Code</th>
|
||||
<th class="px-3 py-2">Name</th>
|
||||
<th class="px-3 py-2">Type</th>
|
||||
<th class="px-3 py-2">Account</th>
|
||||
<th class="px-3 py-2">For</th>
|
||||
<th class="px-3 py-2 hidden md:table-cell">Preview</th>
|
||||
<th class="px-3 py-2 hidden lg:table-cell">Updated</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each results as ds (ds.id ?? ds.data_store_id)}
|
||||
<tr
|
||||
class="border-surface-500/10 hover:bg-surface-500/5 cursor-pointer border-b transition-colors"
|
||||
onclick={() => open_edit(ds)}>
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-mono" class:opacity-40={!ds.enable}>{ds.code}</span>
|
||||
{#if !ds.enable}
|
||||
<span class="badge preset-tonal-error ml-1 text-[9px]">off</span>
|
||||
{/if}
|
||||
{#if ds.hide}
|
||||
<span class="badge preset-tonal-surface ml-1 text-[9px]">hidden</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="max-w-40 truncate px-3 py-2">{ds.name ?? '—'}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge {type_badge(ds.type)} font-mono text-[9px] uppercase">{ds.type ?? '?'}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono opacity-60">
|
||||
{#if ds.account_id}
|
||||
<span title={ds.account_id}>{ds.account_id.slice(0, 8)}…</span>
|
||||
{:else}
|
||||
<span class="italic opacity-50">global</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono opacity-60">
|
||||
{#if ds.for_type}
|
||||
{ds.for_type}
|
||||
{#if ds.for_id}
|
||||
<span class="opacity-50"> / {ds.for_id.slice(0, 6)}…</span>
|
||||
{/if}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
<td class="hidden max-w-48 truncate px-3 py-2 opacity-40 md:table-cell">
|
||||
{content_preview(ds)}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 opacity-50 lg:table-cell whitespace-nowrap">
|
||||
{fmt_date(ds.updated_on)}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-primary text-xs"
|
||||
onclick={(e) => { e.stopPropagation(); open_edit(ds); }}>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if searched && !loading}
|
||||
<div class="py-12 text-center opacity-50">
|
||||
<Database size={48} class="mx-auto mb-4 opacity-30" />
|
||||
<p class="text-lg font-bold">No data stores found</p>
|
||||
<p class="text-sm">Try different filters, or create one above.</p>
|
||||
</div>
|
||||
|
||||
{:else if !searched}
|
||||
<div class="py-12 text-center opacity-40">
|
||||
<Search size={40} class="mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">Enter filters and press Search, or just press Search to list all.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Edit / Create Modal ───────────────────────────────────────────────────── -->
|
||||
<Modal
|
||||
title={is_new ? 'New Data Store' : `Edit: ${editing_obj?.code ?? ''}`}
|
||||
bind:open={show_edit}
|
||||
autoclose={false}
|
||||
outsideclose={submit_status !== 'processing'}
|
||||
size="xl"
|
||||
class="w-full max-w-5xl">
|
||||
|
||||
<form
|
||||
class="space-y-4"
|
||||
onsubmit={(e) => { e.preventDefault(); handle_save(); }}>
|
||||
|
||||
<!-- Code + Name -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label class="label space-y-1">
|
||||
<span class="text-xs font-bold opacity-70">Code <span class="text-error-500">*</span></span>
|
||||
<input
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
bind:value={draft_code}
|
||||
required
|
||||
placeholder="my_data_store_code" />
|
||||
</label>
|
||||
<label class="label space-y-1">
|
||||
<span class="text-xs font-bold opacity-70">Name <span class="text-error-500">*</span></span>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={draft_name}
|
||||
required
|
||||
placeholder="Human-readable name" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Type + Account ID -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label class="label space-y-1">
|
||||
<span class="text-xs font-bold opacity-70">Type</span>
|
||||
<select class="select" bind:value={draft_type}>
|
||||
<option value="text">Text</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="md">Markdown</option>
|
||||
<option value="sql">SQL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="label space-y-1">
|
||||
<span class="text-xs font-bold opacity-70">
|
||||
Account ID
|
||||
<span class="font-normal opacity-60">— blank = global</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
bind:value={draft_account_id}
|
||||
placeholder="Leave blank for global (no account)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- For Type + For ID -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label class="label space-y-1">
|
||||
<span class="text-xs font-bold opacity-70">
|
||||
For Type
|
||||
<span class="font-normal opacity-60">— polymorphic parent object type</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
bind:value={draft_for_type}
|
||||
placeholder="event, event_session, person, journal_entry…" />
|
||||
</label>
|
||||
<div class="space-y-1">
|
||||
<span class="text-xs font-bold opacity-70">
|
||||
For ID
|
||||
<span class="font-normal opacity-60">— random string ID of parent</span>
|
||||
</span>
|
||||
{#if is_new}
|
||||
<input
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
bind:value={draft_for_id}
|
||||
placeholder="random string ID" />
|
||||
{:else}
|
||||
<!-- for_id is stored as an integer FK in DB; backend resolves string→int on create
|
||||
but does not accept the string on PATCH. Shown read-only here. -->
|
||||
<div class="input bg-surface-200-700-token flex items-center font-mono text-sm opacity-70">
|
||||
{#if editing_obj?.for_id}
|
||||
{editing_obj.for_id}
|
||||
{:else}
|
||||
<span class="italic opacity-50">not set</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-[10px] opacity-50">
|
||||
For ID is set at creation time. To change it, use phpMyAdmin (DB stores the integer FK).
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flags row -->
|
||||
<div class="border-surface-500/20 flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border bg-black/5 px-4 py-3">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="checkbox" class="checkbox" bind:checked={draft_enable} />
|
||||
Enable
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="checkbox" class="checkbox" bind:checked={draft_hide} />
|
||||
Hide
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="checkbox" class="checkbox" bind:checked={draft_priority} />
|
||||
Priority
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<span class="opacity-70 shrink-0">Sort:</span>
|
||||
<input type="text" class="input input-sm w-20 font-mono text-xs" bind:value={draft_sort} placeholder="0" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<span class="opacity-70 shrink-0">Group:</span>
|
||||
<input type="text" class="input input-sm w-28 font-mono text-xs" bind:value={draft_group} placeholder="group" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<label class="label space-y-1">
|
||||
<span class="text-xs font-bold opacity-70">Notes</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
bind:value={draft_notes}
|
||||
placeholder="Optional notes" />
|
||||
</label>
|
||||
|
||||
<!-- Content editor -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-bold opacity-70">Content</span>
|
||||
{#if draft_type === 'html'}
|
||||
<div class="flex items-center gap-1 rounded bg-black/5 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase transition-all"
|
||||
class:bg-primary-500={html_edit_mode === 'source'}
|
||||
class:text-white={html_edit_mode === 'source'}
|
||||
class:opacity-50={html_edit_mode !== 'source'}
|
||||
onclick={() => (html_edit_mode = 'source')}
|
||||
title="Edit raw HTML source">
|
||||
<Code size="10" /> Source
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase transition-all"
|
||||
class:bg-primary-500={html_edit_mode === 'visual'}
|
||||
class:text-white={html_edit_mode === 'visual'}
|
||||
class:opacity-50={html_edit_mode !== 'visual'}
|
||||
onclick={() => (html_edit_mode = 'visual')}
|
||||
title="Visual / WYSIWYG editor">
|
||||
<Eye size="10" /> Visual
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if draft_type === 'html'}
|
||||
{#if html_edit_mode === 'source'}
|
||||
<AE_Comp_Editor_CodeMirror bind:content={draft_value} placeholder="Enter HTML source…" />
|
||||
{:else}
|
||||
<AE_Comp_Editor_TipTap bind:content={draft_value} placeholder="Enter HTML content…" />
|
||||
{/if}
|
||||
{:else if draft_type === 'json' || draft_type === 'sql' || draft_type === 'md'}
|
||||
<AE_Comp_Editor_CodeMirror bind:content={draft_value} placeholder="Enter {draft_type.toUpperCase()} content…" />
|
||||
{:else}
|
||||
<textarea
|
||||
bind:value={draft_value}
|
||||
class="textarea font-mono text-sm"
|
||||
rows="12"
|
||||
placeholder="Enter text content…"></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="flex items-center justify-between border-t border-surface-500/20 pt-4">
|
||||
{#if !is_new}
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-tonal-error"
|
||||
onclick={handle_delete}
|
||||
title="Permanently delete this data store — cannot be undone">
|
||||
<Trash2 size={14} class="mr-2" /> Delete
|
||||
</button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-tonal-surface"
|
||||
onclick={() => (show_edit = false)}
|
||||
title="Discard changes and close">
|
||||
<X size={14} class="mr-2" /> Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn preset-filled-primary"
|
||||
disabled={submit_status === 'processing'}
|
||||
title={is_new ? 'Create new data store' : 'Save changes'}>
|
||||
{#if submit_status === 'processing'}
|
||||
<LoaderCircle size={14} class="mr-2 animate-spin" />
|
||||
{:else if submit_status === 'saved'}
|
||||
<Check size={14} class="mr-2" />
|
||||
{:else if submit_status === 'error'}
|
||||
<X size={14} class="mr-2 text-error-500" />
|
||||
{:else}
|
||||
<Save size={14} class="mr-2" />
|
||||
{/if}
|
||||
{is_new ? 'Create' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
5
src/routes/core/data_stores/+page.ts
Normal file
5
src/routes/core/data_stores/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
Reference in New Issue
Block a user