549 lines
19 KiB
Svelte
549 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { onMount, untrack } from 'svelte';
|
|
import { Modal } from 'flowbite-svelte';
|
|
import { liveQuery } from 'dexie';
|
|
|
|
import { api } from '$lib/api/api';
|
|
import { ae_loc, ae_sess, ae_api, slct } 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 { key_val } from '$lib/stores/ae_stores';
|
|
import type { ae_DataStore } from '$lib/types/ae_types';
|
|
|
|
interface Props {
|
|
log_lvl?: number;
|
|
expire_minutes?: number;
|
|
mount_reload_sec?: number;
|
|
ds_code: string;
|
|
ds_name?: null | string;
|
|
ds_type?: string;
|
|
for_type?: null | string;
|
|
for_id?: null | string;
|
|
class_li?: string;
|
|
display?: string;
|
|
try_cache?: boolean;
|
|
hide?: boolean;
|
|
show_edit?: boolean;
|
|
show_edit_btn?: boolean;
|
|
show_view?: boolean;
|
|
ds_loaded?: boolean;
|
|
debug?: boolean;
|
|
ds_loading_status?: string;
|
|
val_sql?: null | any;
|
|
}
|
|
|
|
let {
|
|
log_lvl = 0,
|
|
expire_minutes = 15,
|
|
mount_reload_sec = 0,
|
|
ds_code,
|
|
ds_name = null,
|
|
ds_type = 'text',
|
|
for_type = null,
|
|
for_id = null,
|
|
class_li = '',
|
|
display = undefined as string | undefined,
|
|
try_cache = true,
|
|
hide = false,
|
|
show_edit = $bindable(false),
|
|
show_edit_btn = false,
|
|
show_view = $bindable(true),
|
|
ds_loaded = $bindable(false),
|
|
debug = false,
|
|
ds_loading_status = $bindable('starting'),
|
|
val_sql = $bindable(null)
|
|
}: Props = $props();
|
|
|
|
// Local reactive state
|
|
let trigger: null | string = $state(null);
|
|
let ds_submit_results: Promise<any> | key_val | undefined = $state();
|
|
|
|
// Dexie LiveQuery for data store
|
|
// This derived observable will automatically update when dependencies change
|
|
let lq__ds_obj = $derived(
|
|
liveQuery(async () => {
|
|
const current_code = ds_code;
|
|
const account_id = $ae_loc.account_id;
|
|
const current_for_type = for_type;
|
|
const current_for_id = for_id;
|
|
|
|
if (!current_code) return null;
|
|
|
|
if (log_lvl)
|
|
console.log(`ae_e_data_store [${current_code}]: LQ Lookup...`, {
|
|
account_id,
|
|
current_for_type,
|
|
current_for_id
|
|
});
|
|
|
|
// Hierarchical Local Lookup (Specific -> Account -> Global)
|
|
// Mimics backend SQL priority: WHERE code = :code ORDER BY for_id DESC, account_id DESC
|
|
if (log_lvl)
|
|
console.log(
|
|
`ae_e_data_store [${current_code}]: Fetching all matching codes for priority sorting...`
|
|
);
|
|
|
|
const results = await db_core.data_store
|
|
.where('code')
|
|
.equals(current_code)
|
|
.toArray();
|
|
|
|
if (!results || results.length === 0) return null;
|
|
|
|
// Sort by specificity
|
|
results.sort((a, b) => {
|
|
// 1. Priority: Specific Context match (for_type + for_id)
|
|
const a_context =
|
|
current_for_id &&
|
|
a.for_id === current_for_id &&
|
|
a.for_type === current_for_type
|
|
? 1
|
|
: 0;
|
|
const b_context =
|
|
current_for_id &&
|
|
b.for_id === current_for_id &&
|
|
b.for_type === current_for_type
|
|
? 1
|
|
: 0;
|
|
if (a_context !== b_context) return b_context - a_context;
|
|
|
|
// 2. Priority: Account-specific match
|
|
const a_account = account_id && a.account_id === account_id ? 1 : 0;
|
|
const b_account = account_id && b.account_id === account_id ? 1 : 0;
|
|
if (a_account !== b_account) return b_account - a_account;
|
|
|
|
// 3. Tie-breaker: Newest updated
|
|
const a_time = new Date(
|
|
a.updated_on || a.created_on || 0
|
|
).getTime();
|
|
const b_time = new Date(
|
|
b.updated_on || b.created_on || 0
|
|
).getTime();
|
|
return b_time - a_time;
|
|
});
|
|
|
|
if (log_lvl)
|
|
console.log(
|
|
`ae_e_data_store [${current_code}]: Best match found (ID: ${results[0].id}, Account: ${results[0].account_id})`
|
|
);
|
|
return results[0];
|
|
})
|
|
);
|
|
|
|
// Sync status and bound props when the live data changes
|
|
$effect(() => {
|
|
const entry = $lq__ds_obj as ae_DataStore | null;
|
|
|
|
untrack(() => {
|
|
ds_loaded = !!entry;
|
|
if (entry) {
|
|
ds_loading_status = 'loaded';
|
|
// Handle val_sql binding if type is sql
|
|
if (ds_type === 'sql') {
|
|
val_sql = entry.text || entry.html || null;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Context Change Guard: reset loading status when identity props change.
|
|
// WHY: ds_loading_status persists after the initial load cycle ('loaded', 'not found', etc.).
|
|
// When for_id/for_type/ds_code change after mount (e.g. for_id resolves from undefined
|
|
// to a real event_id), the trigger effect below won't re-fire unless we reset to 'starting'.
|
|
// untrack() prevents a circular dep — we write ds_loading_status but don't subscribe to it here.
|
|
$effect(() => {
|
|
void for_id;
|
|
void for_type;
|
|
void ds_code;
|
|
untrack(() => {
|
|
ds_loading_status = 'starting';
|
|
});
|
|
});
|
|
|
|
// Initial Trigger & Context Change Guard
|
|
$effect(() => {
|
|
const account_id = $slct.account_id;
|
|
const api_ready = !!$ae_api?.base_url;
|
|
const entry = $lq__ds_obj;
|
|
|
|
if (browser && api_ready && !entry && ds_loading_status === 'starting') {
|
|
trigger = 'load__ds__code';
|
|
}
|
|
});
|
|
|
|
// Fetch handler
|
|
$effect(() => {
|
|
if (trigger === 'load__ds__code') {
|
|
untrack(() => {
|
|
trigger = null;
|
|
load_data_store();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mount reload logic
|
|
onMount(() => {
|
|
if (mount_reload_sec > 0) {
|
|
const random_ms = Math.floor(Math.random() * mount_reload_sec * 1000);
|
|
setTimeout(() => {
|
|
trigger = 'load__ds__code';
|
|
}, random_ms);
|
|
}
|
|
});
|
|
|
|
async function load_data_store() {
|
|
if (ds_loading_status === 'loading') return;
|
|
ds_loading_status = 'loading';
|
|
const api_cfg = untrack(() => $ae_api);
|
|
|
|
if (log_lvl) console.log(`ae_e_data_store [${ds_code}]: Fetching...`);
|
|
|
|
try {
|
|
// Attempt 1: Context-specific fetch
|
|
let ds_results = await api.get_data_store({
|
|
api_cfg,
|
|
code: ds_code,
|
|
for_type: for_type,
|
|
for_id: for_id,
|
|
log_lvl: log_lvl
|
|
});
|
|
|
|
// V3 API structured check
|
|
const is_error = ds_results?.meta?.success === false;
|
|
const status_code =
|
|
ds_results?.meta?.status_code || (ds_results === false ? 500 : 200);
|
|
|
|
// Fallback to Global if not found (404), unauthorized (403/401), or explicitly failed
|
|
if (
|
|
!ds_results ||
|
|
is_error ||
|
|
status_code === 404 ||
|
|
status_code === 403 ||
|
|
status_code === 401
|
|
) {
|
|
if (log_lvl)
|
|
console.log(
|
|
`ae_e_data_store [${ds_code}]: Not found in context (Status ${status_code}). Trying global fallback.`
|
|
);
|
|
|
|
ds_results = await api.get_data_store({
|
|
api_cfg,
|
|
code: ds_code,
|
|
no_account_id: true,
|
|
log_lvl: log_lvl
|
|
});
|
|
}
|
|
|
|
const ds_id = ds_results?.data_store_id || ds_results?.id;
|
|
|
|
if (ds_results && ds_id) {
|
|
// Map fields correctly for V3 alignment
|
|
const text_val = ds_results.text || '';
|
|
const json_val =
|
|
ds_results.json ||
|
|
(ds_results.json_str ? JSON.parse(ds_results.json_str) : null);
|
|
|
|
// Save to Dexie
|
|
const ds_to_save: ae_DataStore = {
|
|
...ds_results,
|
|
id: ds_id,
|
|
data_store_id: ds_results.data_store_id || ds_id,
|
|
// data_store_id: ds_id,
|
|
account_id: ds_results.account_id || ds_results.account_id,
|
|
// account_id: ds_results.account_id || ds_results.account_id,
|
|
updated_on: ds_results.updated_on || new Date().toISOString(),
|
|
text: text_val,
|
|
html: text_val, // Default map text to html
|
|
json: json_val
|
|
};
|
|
|
|
await db_core.data_store.put(ds_to_save);
|
|
if (log_lvl)
|
|
console.log(
|
|
`ae_e_data_store [${ds_code}]: Saved to Dexie. ID: ${ds_id}`
|
|
);
|
|
} else {
|
|
ds_loading_status = 'not found';
|
|
if (log_lvl)
|
|
console.warn(
|
|
`ae_e_data_store [${ds_code}]: Result had no valid ID.`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error(`ae_e_data_store [${ds_code}]: Fetch failed.`, err);
|
|
ds_loading_status = 'error';
|
|
}
|
|
}
|
|
|
|
async function handle_submit_form(event: Event) {
|
|
const target = event.target as HTMLFormElement;
|
|
$ae_sess.ds.submit_status = 'processing';
|
|
|
|
const form_data = new FormData(target);
|
|
const data_store_di = ae_util.extract_prefixed_form_data({
|
|
prefix: null,
|
|
form_data,
|
|
trim_values: true,
|
|
bool_tf_str: true
|
|
});
|
|
|
|
const data_store_do: key_val = {
|
|
code: data_store_di.ds_code ?? ds_code,
|
|
name: data_store_di.ds_name ?? ds_name,
|
|
type: data_store_di.ds_type ?? ds_type,
|
|
for_type: data_store_di.ds_for_type ?? null,
|
|
for_id: data_store_di.ds_for_id ?? null,
|
|
access_read: data_store_di.ds_access_read,
|
|
access_write: data_store_di.ds_access_write,
|
|
access_delete: data_store_di.ds_access_delete,
|
|
enable: data_store_di.ds_enable ?? true,
|
|
account_id: data_store_di.ds_use_account_id
|
|
? (data_store_di.ds_account_id ?? $slct.account_id)
|
|
: null
|
|
};
|
|
|
|
const content_val = data_store_di.ds_value;
|
|
if (data_store_do.type === 'json') {
|
|
data_store_do.json = content_val;
|
|
try {
|
|
// Ensure it's valid JSON if stringified
|
|
if (typeof content_val === 'string') JSON.parse(content_val);
|
|
} catch (e) {
|
|
console.error('Invalid JSON content');
|
|
}
|
|
} else {
|
|
data_store_do.text = content_val;
|
|
}
|
|
|
|
const api_cfg = untrack(() => $ae_api);
|
|
|
|
if ($lq__ds_obj?.id) {
|
|
ds_submit_results = api
|
|
.update_ae_obj({
|
|
api_cfg,
|
|
obj_type: 'data_store',
|
|
obj_id: $lq__ds_obj.id,
|
|
fields: data_store_do
|
|
})
|
|
.then((res) => {
|
|
if (res) {
|
|
$ae_sess.ds.submit_status = 'updated';
|
|
trigger = 'load__ds__code';
|
|
}
|
|
return res;
|
|
});
|
|
} else {
|
|
ds_submit_results = api
|
|
.create_ae_obj({
|
|
api_cfg,
|
|
obj_type: 'data_store',
|
|
fields: data_store_do
|
|
})
|
|
.then((res) => {
|
|
if (res) {
|
|
$ae_sess.ds.submit_status = 'created';
|
|
trigger = 'load__ds__code';
|
|
}
|
|
return res;
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handle_delete() {
|
|
if (
|
|
!$lq__ds_obj?.id ||
|
|
!confirm('Are you sure you want to delete this data store?')
|
|
)
|
|
return;
|
|
|
|
const api_cfg = untrack(() => $ae_api);
|
|
const res = await api.delete_ae_obj({
|
|
api_cfg,
|
|
obj_type: 'data_store',
|
|
obj_id: $lq__ds_obj.id,
|
|
method: 'delete'
|
|
});
|
|
|
|
if (res) {
|
|
await db_core.data_store.delete($lq__ds_obj.id);
|
|
ds_loading_status = 'not found';
|
|
show_edit = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="ae__elem__data_store relative {class_li}"
|
|
class:hidden={hide}
|
|
style={display ? `display: ${display}` : undefined}>
|
|
{#if $lq__ds_obj}
|
|
{#if debug || $ae_loc.debug === 'debug'}
|
|
Debug is ON!
|
|
<pre
|
|
class="mb-2 overflow-x-auto rounded bg-black/10 p-2 text-[10px]">
|
|
ID: {$lq__ds_obj.id}
|
|
Code: {$lq__ds_obj.code}
|
|
Name: {$lq__ds_obj.name}
|
|
Type: {$lq__ds_obj.type}
|
|
Account: {$lq__ds_obj.account_id || 'Global / NULL'}
|
|
Created: {$lq__ds_obj.created_on}
|
|
Updated: {$lq__ds_obj.updated_on}
|
|
</pre>
|
|
|
|
<hr />
|
|
{/if}
|
|
|
|
<Modal
|
|
title="{$lq__ds_obj.name || 'Unnamed'} - {$lq__ds_obj.code}"
|
|
bind:open={show_edit}
|
|
autoclose={false}
|
|
size="xl"
|
|
class="w-full max-w-6xl">
|
|
<form
|
|
class="flex flex-col gap-4"
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
handle_submit_form(e);
|
|
}}>
|
|
<input
|
|
type="hidden"
|
|
name="ds_id_random"
|
|
value={$lq__ds_obj.id} />
|
|
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div class="space-y-2">
|
|
<label class="label">
|
|
<span class="text-xs font-bold opacity-70"
|
|
>Code</span>
|
|
<input
|
|
type="text"
|
|
name="ds_code"
|
|
class="input font-mono"
|
|
value={$lq__ds_obj.code}
|
|
readonly={!$ae_loc.manager_access}
|
|
required />
|
|
</label>
|
|
<label class="label">
|
|
<span class="text-xs font-bold opacity-70"
|
|
>Name</span>
|
|
<input
|
|
type="text"
|
|
name="ds_name"
|
|
class="input"
|
|
value={$lq__ds_obj.name}
|
|
required />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<label class="label">
|
|
<span class="text-xs font-bold opacity-70"
|
|
>Type</span>
|
|
<select
|
|
name="ds_type"
|
|
class="select"
|
|
value={$lq__ds_obj.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>
|
|
<div class="flex items-center gap-2 pt-6">
|
|
<input
|
|
type="checkbox"
|
|
name="ds_use_account_id"
|
|
class="checkbox"
|
|
checked={!!$lq__ds_obj.account_id} />
|
|
<span class="text-xs">Account Specific</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<span class="text-xs font-bold opacity-70">Content</span>
|
|
<textarea
|
|
name="ds_value"
|
|
class="textarea font-mono text-sm"
|
|
rows="15"
|
|
placeholder="Enter content here..."
|
|
>{$lq__ds_obj.type === 'json'
|
|
? typeof $lq__ds_obj.json === 'string'
|
|
? $lq__ds_obj.json
|
|
: JSON.stringify($lq__ds_obj.json, null, 2)
|
|
: $lq__ds_obj.text ||
|
|
$lq__ds_obj.html ||
|
|
''}</textarea>
|
|
</div>
|
|
|
|
<div class="text-surface-500 text-xs">
|
|
Created on: {$lq__ds_obj.created_on} | Last Updated: {$lq__ds_obj.updated_on}
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between pt-4">
|
|
<button
|
|
type="button"
|
|
class="btn variant-filled-error"
|
|
onclick={handle_delete}>
|
|
<span class="fas fa-trash mr-2"></span> Delete
|
|
</button>
|
|
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
class="btn variant-soft"
|
|
onclick={() => (show_edit = false)}>Cancel</button>
|
|
<button
|
|
type="submit"
|
|
class="btn variant-filled-primary">
|
|
<span class="fas fa-save mr-2"></span> Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{#if show_view}
|
|
{#if $lq__ds_obj.type === 'html' && $lq__ds_obj.html}
|
|
{@html $lq__ds_obj.html}
|
|
{:else if $lq__ds_obj.type === 'text' && $lq__ds_obj.text}
|
|
<div class="whitespace-pre-wrap">{$lq__ds_obj.text}</div>
|
|
{:else if $lq__ds_obj.type === 'sql' && $lq__ds_obj.text}
|
|
{#if debug}<div class="font-mono text-xs opacity-50">
|
|
SQL: {$lq__ds_obj.text}
|
|
</div>{/if}
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if $ae_loc.edit_mode && ($ae_loc.manager_access || (show_edit_btn && $ae_loc.administrator_access))}
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm variant-soft-warning absolute top-0 right-0 z-10 opacity-20 hover:opacity-100"
|
|
ondblclick={() => {
|
|
show_edit = true;
|
|
show_view = false;
|
|
}}
|
|
title="Edit Data Store: {ds_code}">
|
|
<span class="fas fa-edit"></span>
|
|
</button>
|
|
{/if}
|
|
{:else if ds_loading_status === 'not found'}
|
|
<!-- Only show diagnostic to administrator+ (no edit_mode needed) or trusted staff in edit mode.
|
|
Anonymous/user/public visitors must never see internal data store codes or gaps. -->
|
|
{#if $ae_loc.administrator_access || ($ae_loc.trusted_access && $ae_loc.edit_mode)}
|
|
<div
|
|
class="border-surface-500/30 rounded border border-dashed p-2 text-xs opacity-50">
|
|
Data Store not found: {ds_code}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if ds_loading_status === 'loading'}
|
|
<div class="absolute bottom-0 left-0 p-1 opacity-50">
|
|
<span class="fas fa-spinner fa-spin text-xs"></span>
|
|
</div>
|
|
{/if}
|
|
</div>
|