Last round of prettier: npx prettier --write src/
This commit is contained in:
@@ -13,75 +13,82 @@
|
||||
*/
|
||||
|
||||
export interface FitTextParams {
|
||||
min?: number;
|
||||
max?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function fit_text(
|
||||
node: HTMLElement,
|
||||
params: FitTextParams | null | undefined
|
||||
node: HTMLElement,
|
||||
params: FitTextParams | null | undefined
|
||||
) {
|
||||
if (!params) return {};
|
||||
if (!params) return {};
|
||||
|
||||
let { min = 16, max = 80 } = params;
|
||||
let { min = 16, max = 80 } = params;
|
||||
|
||||
// overflow:hidden is required so scrollWidth/scrollHeight accurately reflect overflow
|
||||
const prev_overflow = node.style.overflow;
|
||||
node.style.overflow = 'hidden';
|
||||
// overflow:hidden is required so scrollWidth/scrollHeight accurately reflect overflow
|
||||
const prev_overflow = node.style.overflow;
|
||||
node.style.overflow = 'hidden';
|
||||
|
||||
function fits(): boolean {
|
||||
return node.scrollWidth <= node.offsetWidth && node.scrollHeight <= node.offsetHeight;
|
||||
}
|
||||
function fits(): boolean {
|
||||
return (
|
||||
node.scrollWidth <= node.offsetWidth &&
|
||||
node.scrollHeight <= node.offsetHeight
|
||||
);
|
||||
}
|
||||
|
||||
function fit() {
|
||||
if (!params) return;
|
||||
function fit() {
|
||||
if (!params) return;
|
||||
|
||||
// Try max first — if it fits, no search needed
|
||||
node.style.fontSize = max + 'px';
|
||||
if (fits()) return;
|
||||
// Try max first — if it fits, no search needed
|
||||
node.style.fontSize = max + 'px';
|
||||
if (fits()) return;
|
||||
|
||||
// Binary search between min and max
|
||||
let lo = min;
|
||||
let hi = max;
|
||||
while (lo < hi - 1) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
node.style.fontSize = mid + 'px';
|
||||
if (fits()) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
node.style.fontSize = lo + 'px';
|
||||
}
|
||||
// Binary search between min and max
|
||||
let lo = min;
|
||||
let hi = max;
|
||||
while (lo < hi - 1) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
node.style.fontSize = mid + 'px';
|
||||
if (fits()) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
node.style.fontSize = lo + 'px';
|
||||
}
|
||||
|
||||
// Re-fit when text content changes (handles {@html} updates)
|
||||
const mutation_observer = new MutationObserver(fit);
|
||||
mutation_observer.observe(node, { childList: true, subtree: true, characterData: true });
|
||||
// Re-fit when text content changes (handles {@html} updates)
|
||||
const mutation_observer = new MutationObserver(fit);
|
||||
mutation_observer.observe(node, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
|
||||
// Re-fit when the container is resized (e.g. window resize, panel open/close)
|
||||
const resize_observer = new ResizeObserver(fit);
|
||||
resize_observer.observe(node);
|
||||
// Re-fit when the container is resized (e.g. window resize, panel open/close)
|
||||
const resize_observer = new ResizeObserver(fit);
|
||||
resize_observer.observe(node);
|
||||
|
||||
// Defer initial fit to after the DOM has laid out
|
||||
requestAnimationFrame(fit);
|
||||
// Defer initial fit to after the DOM has laid out
|
||||
requestAnimationFrame(fit);
|
||||
|
||||
return {
|
||||
update(new_params: FitTextParams | null | undefined) {
|
||||
if (!new_params) {
|
||||
node.style.overflow = prev_overflow;
|
||||
return;
|
||||
}
|
||||
params = new_params;
|
||||
min = new_params.min ?? 16;
|
||||
max = new_params.max ?? 80;
|
||||
node.style.overflow = 'hidden';
|
||||
requestAnimationFrame(fit);
|
||||
},
|
||||
destroy() {
|
||||
mutation_observer.disconnect();
|
||||
resize_observer.disconnect();
|
||||
node.style.overflow = prev_overflow;
|
||||
}
|
||||
};
|
||||
return {
|
||||
update(new_params: FitTextParams | null | undefined) {
|
||||
if (!new_params) {
|
||||
node.style.overflow = prev_overflow;
|
||||
return;
|
||||
}
|
||||
params = new_params;
|
||||
min = new_params.min ?? 16;
|
||||
max = new_params.max ?? 80;
|
||||
node.style.overflow = 'hidden';
|
||||
requestAnimationFrame(fit);
|
||||
},
|
||||
destroy() {
|
||||
mutation_observer.disconnect();
|
||||
resize_observer.disconnect();
|
||||
node.style.overflow = prev_overflow;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@ export async function ensure_CodeMirror_modules(): Promise<CMCache> {
|
||||
|
||||
EditorState: stateMod.EditorState,
|
||||
EditorSelection: stateMod.EditorSelection,
|
||||
EditorState_allowMultipleSelections: stateMod.EditorState.allowMultipleSelections,
|
||||
EditorState_allowMultipleSelections:
|
||||
stateMod.EditorState.allowMultipleSelections,
|
||||
EditorState_readOnly: stateMod.EditorState.readOnly,
|
||||
|
||||
markdown: markdownMod?.markdown,
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { ShieldX } from '@lucide/svelte';
|
||||
import { ShieldX } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
message?: string;
|
||||
action_label?: string;
|
||||
on_action?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
title?: string;
|
||||
message?: string;
|
||||
action_label?: string;
|
||||
on_action?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title = 'Access Denied',
|
||||
message = 'You do not have permission to view this content.',
|
||||
action_label = '',
|
||||
on_action = undefined
|
||||
}: Props = $props();
|
||||
let {
|
||||
title = 'Access Denied',
|
||||
message = 'You do not have permission to view this content.',
|
||||
action_label = '',
|
||||
on_action = undefined
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card p-6 space-y-4 max-w-sm">
|
||||
<div class="flex items-center gap-2 text-error-500">
|
||||
<div class="card max-w-sm space-y-4 p-6">
|
||||
<div class="text-error-500 flex items-center gap-2">
|
||||
<ShieldX size="1.2em" />
|
||||
<h3 class="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
@@ -26,8 +26,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-tonal-surface w-full"
|
||||
onclick={on_action}
|
||||
>
|
||||
onclick={on_action}>
|
||||
{action_label}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,176 +1,198 @@
|
||||
<script lang="ts">
|
||||
// import { browser } from '$app/environment';
|
||||
import { untrack } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Check, CircleAlert, LoaderCircle, Save, SquarePen, Trash2, X } from '@lucide/svelte';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { api } from '$lib/api/api';
|
||||
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
|
||||
// import { browser } from '$app/environment';
|
||||
import { untrack } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
Check,
|
||||
CircleAlert,
|
||||
LoaderCircle,
|
||||
Save,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
X
|
||||
} from '@lucide/svelte';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { api } from '$lib/api/api';
|
||||
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
|
||||
|
||||
interface Props {
|
||||
// Core Identifiers
|
||||
id?: string;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
field_name: string;
|
||||
interface Props {
|
||||
// Core Identifiers
|
||||
id?: string;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
field_name: string;
|
||||
|
||||
// Value Handling
|
||||
current_value: any;
|
||||
field_type?: 'text' | 'textarea' | 'select' | 'tiptap' | 'checkbox' | 'date' | 'datetime' | 'number';
|
||||
allow_null?: boolean;
|
||||
// Value Handling
|
||||
current_value: any;
|
||||
field_type?:
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'tiptap'
|
||||
| 'checkbox'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'number';
|
||||
allow_null?: boolean;
|
||||
|
||||
// Select Options
|
||||
select_options?: key_val; // { value: label }
|
||||
// Select Options
|
||||
select_options?: key_val; // { value: label }
|
||||
|
||||
// UI Configuration
|
||||
edit_label?: string;
|
||||
display_block?: boolean;
|
||||
display_absolute_edit?: boolean;
|
||||
placeholder?: string;
|
||||
class_li?: string;
|
||||
textarea_rows?: number;
|
||||
// UI Configuration
|
||||
edit_label?: string;
|
||||
display_block?: boolean;
|
||||
display_absolute_edit?: boolean;
|
||||
placeholder?: string;
|
||||
class_li?: string;
|
||||
textarea_rows?: number;
|
||||
|
||||
// Behavior
|
||||
object_reload?: boolean; // SWR pattern
|
||||
log_lvl?: number;
|
||||
// Behavior
|
||||
object_reload?: boolean; // SWR pattern
|
||||
log_lvl?: number;
|
||||
|
||||
// Callbacks
|
||||
on_success?: (data: any) => void;
|
||||
on_error?: (error: any) => void;
|
||||
// Callbacks
|
||||
on_success?: (data: any) => void;
|
||||
on_error?: (error: any) => void;
|
||||
|
||||
// Snippets
|
||||
children?: Snippet;
|
||||
}
|
||||
// Snippets
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
object_type,
|
||||
object_id,
|
||||
field_name,
|
||||
current_value = $bindable(),
|
||||
field_type = 'text',
|
||||
allow_null = false,
|
||||
select_options = {},
|
||||
edit_label = 'Edit Field',
|
||||
display_block = false,
|
||||
display_absolute_edit = false,
|
||||
placeholder = 'Enter value...',
|
||||
class_li = '',
|
||||
textarea_rows = 4,
|
||||
object_reload = true,
|
||||
log_lvl = 0,
|
||||
on_success,
|
||||
on_error,
|
||||
children
|
||||
}: Props = $props();
|
||||
let {
|
||||
id,
|
||||
object_type,
|
||||
object_id,
|
||||
field_name,
|
||||
current_value = $bindable(),
|
||||
field_type = 'text',
|
||||
allow_null = false,
|
||||
select_options = {},
|
||||
edit_label = 'Edit Field',
|
||||
display_block = false,
|
||||
display_absolute_edit = false,
|
||||
placeholder = 'Enter value...',
|
||||
class_li = '',
|
||||
textarea_rows = 4,
|
||||
object_reload = true,
|
||||
log_lvl = 0,
|
||||
on_success,
|
||||
on_error,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
// Internal State
|
||||
let is_editing = $state(false);
|
||||
let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
let error_message = $state('');
|
||||
let draft_value = $state(current_value);
|
||||
// Internal State
|
||||
let is_editing = $state(false);
|
||||
let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
let error_message = $state('');
|
||||
let draft_value = $state(current_value);
|
||||
|
||||
// WHY: Optimistic display. After a successful PATCH, liveQuery may not fire
|
||||
// immediately (or at all if on_success doesn't trigger a Dexie refresh). We show
|
||||
// draft_value as the display until current_value catches up from liveQuery.
|
||||
let has_optimistic = $state(false);
|
||||
let display_value = $derived(has_optimistic ? draft_value : current_value);
|
||||
// WHY: Optimistic display. After a successful PATCH, liveQuery may not fire
|
||||
// immediately (or at all if on_success doesn't trigger a Dexie refresh). We show
|
||||
// draft_value as the display until current_value catches up from liveQuery.
|
||||
let has_optimistic = $state(false);
|
||||
let display_value = $derived(has_optimistic ? draft_value : current_value);
|
||||
|
||||
// Sync draft with display_value when not editing.
|
||||
// Suppress reset if optimistic is active — we already have the right value.
|
||||
$effect(() => {
|
||||
if (!is_editing) {
|
||||
untrack(() => {
|
||||
if (!has_optimistic) {
|
||||
draft_value = current_value;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clear optimistic once liveQuery catches up (current_value matches what we saved)
|
||||
$effect(() => {
|
||||
if (has_optimistic && current_value === draft_value) {
|
||||
has_optimistic = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function handle_patch() {
|
||||
if (log_lvl) console.log(`AE Field Editor V3: Patching ${object_type}.${field_name}...`);
|
||||
|
||||
patch_status = 'processing';
|
||||
error_message = '';
|
||||
|
||||
try {
|
||||
const result = await api.update_ae_obj({
|
||||
api_cfg: $ae_api,
|
||||
obj_type: object_type,
|
||||
obj_id: object_id,
|
||||
fields: {
|
||||
[field_name]: draft_value
|
||||
},
|
||||
log_lvl
|
||||
});
|
||||
|
||||
if (result) {
|
||||
patch_status = 'success';
|
||||
has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
|
||||
if (on_success) on_success(result);
|
||||
|
||||
// Close edit mode after a brief success indicator
|
||||
setTimeout(() => {
|
||||
if (patch_status === 'success') {
|
||||
patch_status = 'idle';
|
||||
is_editing = false;
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error('No data returned from update.');
|
||||
// Sync draft with display_value when not editing.
|
||||
// Suppress reset if optimistic is active — we already have the right value.
|
||||
$effect(() => {
|
||||
if (!is_editing) {
|
||||
untrack(() => {
|
||||
if (!has_optimistic) {
|
||||
draft_value = current_value;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('AE Field Editor V3: Patch failed.', error);
|
||||
patch_status = 'error';
|
||||
error_message = error?.message || 'Update failed.';
|
||||
if (on_error) on_error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function cancel_edit() {
|
||||
// Clear optimistic once liveQuery catches up (current_value matches what we saved)
|
||||
$effect(() => {
|
||||
if (has_optimistic && current_value === draft_value) {
|
||||
has_optimistic = false;
|
||||
draft_value = current_value;
|
||||
is_editing = false;
|
||||
patch_status = 'idle';
|
||||
error_message = '';
|
||||
}
|
||||
});
|
||||
|
||||
function toggle_edit() {
|
||||
if (is_editing) cancel_edit();
|
||||
else {
|
||||
has_optimistic = false; // clear optimistic so draft syncs from current prop
|
||||
draft_value = current_value;
|
||||
is_editing = true;
|
||||
async function handle_patch() {
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`AE Field Editor V3: Patching ${object_type}.${field_name}...`
|
||||
);
|
||||
|
||||
patch_status = 'processing';
|
||||
error_message = '';
|
||||
|
||||
try {
|
||||
const result = await api.update_ae_obj({
|
||||
api_cfg: $ae_api,
|
||||
obj_type: object_type,
|
||||
obj_id: object_id,
|
||||
fields: {
|
||||
[field_name]: draft_value
|
||||
},
|
||||
log_lvl
|
||||
});
|
||||
|
||||
if (result) {
|
||||
patch_status = 'success';
|
||||
has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
|
||||
if (on_success) on_success(result);
|
||||
|
||||
// Close edit mode after a brief success indicator
|
||||
setTimeout(() => {
|
||||
if (patch_status === 'success') {
|
||||
patch_status = 'idle';
|
||||
is_editing = false;
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error('No data returned from update.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('AE Field Editor V3: Patch failed.', error);
|
||||
patch_status = 'error';
|
||||
error_message = error?.message || 'Update failed.';
|
||||
if (on_error) on_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel_edit() {
|
||||
has_optimistic = false;
|
||||
draft_value = current_value;
|
||||
is_editing = false;
|
||||
patch_status = 'idle';
|
||||
error_message = '';
|
||||
}
|
||||
|
||||
function toggle_edit() {
|
||||
if (is_editing) cancel_edit();
|
||||
else {
|
||||
has_optimistic = false; // clear optimistic so draft syncs from current prop
|
||||
draft_value = current_value;
|
||||
is_editing = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ae_field_editor group relative {class_li}"
|
||||
class:block={display_block}
|
||||
class:inline-block={!display_block}
|
||||
>
|
||||
class:inline-block={!display_block}>
|
||||
<!-- VIEW MODE -->
|
||||
<div class="view_wrapper flex items-center gap-2" class:hidden={is_editing}>
|
||||
<div class="content_render grow">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else if field_type === 'checkbox'}
|
||||
<span class="badge {display_value ? 'variant-filled-success' : 'variant-soft-surface'}">
|
||||
<span
|
||||
class="badge {display_value
|
||||
? 'variant-filled-success'
|
||||
: 'variant-soft-surface'}">
|
||||
{display_value ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{:else if field_type === 'tiptap'}
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{@html display_value || '<span class="opacity-50 italic">Empty</span>'}
|
||||
{@html display_value ||
|
||||
'<span class="opacity-50 italic">Empty</span>'}
|
||||
</div>
|
||||
{:else}
|
||||
<span class:opacity-50={!display_value}>
|
||||
@@ -184,12 +206,11 @@
|
||||
Use invisible (visibility:hidden, preserves space) when edit_mode is off. -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon btn-icon-sm variant-soft-warning opacity-20 hover:opacity-100 transition-opacity"
|
||||
class="btn-icon btn-icon-sm variant-soft-warning opacity-20 transition-opacity hover:opacity-100"
|
||||
class:invisible={!$ae_loc.edit_mode}
|
||||
class:pointer-events-none={!$ae_loc.edit_mode}
|
||||
onclick={toggle_edit}
|
||||
title="Edit {field_name}"
|
||||
>
|
||||
title="Edit {field_name}">
|
||||
<SquarePen size="14" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -197,21 +218,21 @@
|
||||
<!-- EDIT MODE -->
|
||||
{#if is_editing}
|
||||
<div
|
||||
class="edit_wrapper p-3 border-2 border-dashed border-warning-500/50 rounded-lg bg-surface-50-950 shadow-xl z-50"
|
||||
class="edit_wrapper border-warning-500/50 bg-surface-50-950 z-50 rounded-lg border-2 border-dashed p-3 shadow-xl"
|
||||
class:absolute={display_absolute_edit}
|
||||
class:top-0={display_absolute_edit}
|
||||
class:left-0={display_absolute_edit}
|
||||
class:w-full={display_absolute_edit}
|
||||
>
|
||||
<header class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wider opacity-60">{edit_label || field_name}</span>
|
||||
class:w-full={display_absolute_edit}>
|
||||
<header class="mb-2 flex items-center justify-between">
|
||||
<span
|
||||
class="text-xs font-bold tracking-wider uppercase opacity-60"
|
||||
>{edit_label || field_name}</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon btn-icon-sm variant-soft-surface"
|
||||
onclick={cancel_edit}
|
||||
disabled={patch_status === 'processing'}
|
||||
>
|
||||
disabled={patch_status === 'processing'}>
|
||||
<X size="14" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -223,8 +244,7 @@
|
||||
bind:value={draft_value}
|
||||
rows={textarea_rows}
|
||||
class="textarea"
|
||||
{placeholder}
|
||||
></textarea>
|
||||
{placeholder}></textarea>
|
||||
{:else if field_type === 'select'}
|
||||
<select bind:value={draft_value} class="select">
|
||||
{#if allow_null}
|
||||
@@ -236,46 +256,56 @@
|
||||
</select>
|
||||
{:else if field_type === 'checkbox'}
|
||||
<label class="flex items-center space-x-2">
|
||||
<input type="checkbox" bind:checked={draft_value} class="checkbox" />
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft_value}
|
||||
class="checkbox" />
|
||||
<span>{draft_value ? 'Enabled' : 'Disabled'}</span>
|
||||
</label>
|
||||
{:else if field_type === 'tiptap'}
|
||||
<AE_Comp_Editor_TipTap bind:content={draft_value} {placeholder} />
|
||||
<AE_Comp_Editor_TipTap
|
||||
bind:content={draft_value}
|
||||
{placeholder} />
|
||||
{:else if field_type === 'date'}
|
||||
<input type="date" bind:value={draft_value} class="input" />
|
||||
{:else if field_type === 'datetime'}
|
||||
<input type="datetime-local" bind:value={draft_value} class="input" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={draft_value}
|
||||
class="input" />
|
||||
{:else if field_type === 'number'}
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft_value}
|
||||
class="input"
|
||||
{placeholder}
|
||||
onkeydown={(e) => e.key === 'Enter' && handle_patch()}
|
||||
/>
|
||||
onkeydown={(e) =>
|
||||
e.key === 'Enter' && handle_patch()} />
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft_value}
|
||||
class="input"
|
||||
{placeholder}
|
||||
onkeydown={(e) => e.key === 'Enter' && handle_patch()}
|
||||
/>
|
||||
onkeydown={(e) =>
|
||||
e.key === 'Enter' && handle_patch()} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-between items-center">
|
||||
<footer class="flex items-center justify-between">
|
||||
<div class="status_indicator text-xs">
|
||||
{#if patch_status === 'processing'}
|
||||
<span class="flex items-center gap-1 text-primary-500">
|
||||
<span class="text-primary-500 flex items-center gap-1">
|
||||
<LoaderCircle size="12" class="animate-spin" /> Saving...
|
||||
</span>
|
||||
{:else if patch_status === 'success'}
|
||||
<span class="flex items-center gap-1 text-success-500">
|
||||
<span class="text-success-500 flex items-center gap-1">
|
||||
<Check size="12" /> Saved
|
||||
</span>
|
||||
{:else if patch_status === 'error'}
|
||||
<span class="flex items-center gap-1 text-error-500" title={error_message}>
|
||||
<span
|
||||
class="text-error-500 flex items-center gap-1"
|
||||
title={error_message}>
|
||||
<CircleAlert size="12" /> Error
|
||||
</span>
|
||||
{/if}
|
||||
@@ -286,8 +316,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft-error"
|
||||
onclick={() => draft_value = null}
|
||||
>
|
||||
onclick={() => (draft_value = null)}>
|
||||
Set Null
|
||||
</button>
|
||||
{/if}
|
||||
@@ -295,10 +324,10 @@
|
||||
type="button"
|
||||
class="btn btn-sm variant-filled-primary"
|
||||
onclick={handle_patch}
|
||||
disabled={patch_status === 'processing' || draft_value === display_value}
|
||||
>
|
||||
disabled={patch_status === 'processing' ||
|
||||
draft_value === display_value}>
|
||||
{#if patch_status === 'processing'}
|
||||
<LoaderCircle size="14" class="animate-spin mr-1" />
|
||||
<LoaderCircle size="14" class="mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Save size="14" class="mr-1" />
|
||||
{/if}
|
||||
@@ -311,9 +340,9 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Add any specialized transitions if needed */
|
||||
.ae_field_editor :global(.btn-icon-sm) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
/* Add any specialized transitions if needed */
|
||||
.ae_field_editor :global(.btn-icon-sm) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,315 +1,373 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { Modal } from 'flowbite-svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
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';
|
||||
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;
|
||||
}
|
||||
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();
|
||||
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();
|
||||
// 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;
|
||||
// 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 (!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}]: LQ Lookup...`, {
|
||||
account_id,
|
||||
current_for_type,
|
||||
current_for_id
|
||||
});
|
||||
|
||||
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];
|
||||
})
|
||||
);
|
||||
// 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...`
|
||||
);
|
||||
|
||||
// Sync status and bound props when the live data changes
|
||||
$effect(() => {
|
||||
const entry = $lq__ds_obj as ae_DataStore | null;
|
||||
const results = await db_core.data_store
|
||||
.where('code')
|
||||
.equals(current_code)
|
||||
.toArray();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initial Trigger & Context Change Guard
|
||||
$effect(() => {
|
||||
const account_id = $slct.account_id;
|
||||
const api_ready = !!$ae_api?.base_url;
|
||||
const entry = $lq__ds_obj;
|
||||
// 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') {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch handler
|
||||
$effect(() => {
|
||||
if (trigger === 'load__ds__code') {
|
||||
untrack(() => {
|
||||
trigger = null;
|
||||
load_data_store();
|
||||
});
|
||||
}
|
||||
});
|
||||
async function load_data_store() {
|
||||
if (ds_loading_status === 'loading') return;
|
||||
ds_loading_status = 'loading';
|
||||
const api_cfg = untrack(() => $ae_api);
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
if (log_lvl) console.log(`ae_e_data_store [${ds_code}]: Fetching...`);
|
||||
|
||||
async function load_data_store() {
|
||||
if (ds_loading_status === 'loading') return;
|
||||
ds_loading_status = 'loading';
|
||||
const api_cfg = untrack(() => $ae_api);
|
||||
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
|
||||
});
|
||||
|
||||
if (log_lvl) console.log(`ae_e_data_store [${ds_code}]: Fetching...`);
|
||||
// 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);
|
||||
|
||||
try {
|
||||
// Attempt 1: Context-specific fetch
|
||||
let ds_results = await api.get_data_store({
|
||||
// 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,
|
||||
for_type: for_type,
|
||||
for_id: for_id,
|
||||
no_account_id: true,
|
||||
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';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function handle_submit_form(event: Event) {
|
||||
const target = event.target as HTMLFormElement;
|
||||
$ae_sess.ds.submit_status = 'processing';
|
||||
const api_cfg = untrack(() => $ae_api);
|
||||
|
||||
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({
|
||||
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) => {
|
||||
})
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
$ae_sess.ds.submit_status = 'updated';
|
||||
trigger = 'load__ds__code';
|
||||
}
|
||||
return res;
|
||||
});
|
||||
} else {
|
||||
ds_submit_results = api.create_ae_obj({
|
||||
} else {
|
||||
ds_submit_results = api
|
||||
.create_ae_obj({
|
||||
api_cfg,
|
||||
obj_type: 'data_store',
|
||||
fields: data_store_do
|
||||
}).then((res) => {
|
||||
})
|
||||
.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;
|
||||
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'
|
||||
});
|
||||
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;
|
||||
}
|
||||
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}>
|
||||
|
||||
<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="text-[10px] bg-black/10 p-2 rounded mb-2 overflow-x-auto">
|
||||
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}
|
||||
@@ -327,27 +385,51 @@
|
||||
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} />
|
||||
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 md:grid-cols-2 gap-4">
|
||||
<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 />
|
||||
<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 />
|
||||
<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}>
|
||||
<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>
|
||||
@@ -356,7 +438,11 @@
|
||||
</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} />
|
||||
<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>
|
||||
@@ -369,22 +455,35 @@
|
||||
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>
|
||||
>{$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-xs text-surface-500">
|
||||
<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 justify-between items-center pt-4">
|
||||
<button type="button" class="btn variant-filled-error" onclick={handle_delete}>
|
||||
<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">
|
||||
<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>
|
||||
@@ -398,17 +497,21 @@
|
||||
{: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 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="absolute top-0 right-0 btn btn-sm variant-soft-warning opacity-20 hover:opacity-100 z-10"
|
||||
ondblclick={() => { show_edit = true; show_view = false; }}
|
||||
title="Edit Data Store: {ds_code}"
|
||||
>
|
||||
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}
|
||||
@@ -416,7 +519,8 @@
|
||||
<!-- 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="p-2 border border-dashed border-surface-500/30 rounded text-xs opacity-50">
|
||||
<div
|
||||
class="border-surface-500/30 rounded border border-dashed p-2 text-xs opacity-50">
|
||||
Data Store not found: {ds_code}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,218 +1,252 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* element_editor_codemirror.svelte
|
||||
* Consolidated CodeMirror 6 Editor for Aether Platform.
|
||||
* Combines technical power with a user-friendly Markdown toolbar.
|
||||
* Uses strictly snake_case and Svelte 5 Runes.
|
||||
*/
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { ensure_CodeMirror_modules } from './codemirror_modules';
|
||||
// import type { key_val } from '$lib/stores/ae_stores';
|
||||
/**
|
||||
* element_editor_codemirror.svelte
|
||||
* Consolidated CodeMirror 6 Editor for Aether Platform.
|
||||
* Combines technical power with a user-friendly Markdown toolbar.
|
||||
* Uses strictly snake_case and Svelte 5 Runes.
|
||||
*/
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { ensure_CodeMirror_modules } from './codemirror_modules';
|
||||
// import type { key_val } from '$lib/stores/ae_stores';
|
||||
|
||||
// Icons (Standardized to Lucide where possible, or FontAwesome placeholders)
|
||||
import { Bold, Code, Italic, List, } from '@lucide/svelte';
|
||||
interface Props {
|
||||
content?: string;
|
||||
new_content?: string;
|
||||
placeholder?: string;
|
||||
theme_mode?: 'light' | 'dark';
|
||||
language?: 'markdown' | 'json' | 'html' | 'javascript';
|
||||
readonly?: boolean;
|
||||
show_line_numbers?: boolean;
|
||||
wrap_lines?: boolean;
|
||||
show_toolbar?: boolean;
|
||||
editor_view?: any; // $bindable for external control
|
||||
class_li?: string;
|
||||
// Icons (Standardized to Lucide where possible, or FontAwesome placeholders)
|
||||
import { Bold, Code, Italic, List } from '@lucide/svelte';
|
||||
interface Props {
|
||||
content?: string;
|
||||
new_content?: string;
|
||||
placeholder?: string;
|
||||
theme_mode?: 'light' | 'dark';
|
||||
language?: 'markdown' | 'json' | 'html' | 'javascript';
|
||||
readonly?: boolean;
|
||||
show_line_numbers?: boolean;
|
||||
wrap_lines?: boolean;
|
||||
show_toolbar?: boolean;
|
||||
editor_view?: any; // $bindable for external control
|
||||
class_li?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
content = $bindable(''),
|
||||
new_content = $bindable(),
|
||||
placeholder = 'Start typing...',
|
||||
theme_mode = 'light',
|
||||
language = 'markdown',
|
||||
readonly = false,
|
||||
show_line_numbers = false,
|
||||
wrap_lines = true,
|
||||
show_toolbar = true,
|
||||
editor_view = $bindable(),
|
||||
class_li = ''
|
||||
}: Props = $props();
|
||||
|
||||
let editor_container: HTMLDivElement | undefined = $state();
|
||||
let cm: any = $state(); // CodeMirror modules cache
|
||||
|
||||
async function create_editor() {
|
||||
if (!browser) return;
|
||||
|
||||
cm = await ensure_CodeMirror_modules();
|
||||
if (!cm) return;
|
||||
|
||||
// Cleanup existing instance if HMR or remount occurs
|
||||
if (editor_view) {
|
||||
editor_view.destroy();
|
||||
editor_view = null;
|
||||
}
|
||||
|
||||
let {
|
||||
content = $bindable(''),
|
||||
new_content = $bindable(),
|
||||
placeholder = 'Start typing...',
|
||||
theme_mode = 'light',
|
||||
language = 'markdown',
|
||||
readonly = false,
|
||||
show_line_numbers = false,
|
||||
wrap_lines = true,
|
||||
show_toolbar = true,
|
||||
editor_view = $bindable(),
|
||||
class_li = ''
|
||||
}: Props = $props();
|
||||
const extensions = [
|
||||
cm.highlightSpecialChars(),
|
||||
cm.history(),
|
||||
cm.drawSelection(),
|
||||
cm.dropCursor(),
|
||||
cm.EditorState_allowMultipleSelections.of(true),
|
||||
cm.indentOnInput(),
|
||||
cm.bracketMatching(),
|
||||
cm.closeBrackets(),
|
||||
cm.autocompletion(),
|
||||
cm.rectangularSelection(),
|
||||
cm.crosshairCursor(),
|
||||
cm.highlightActiveLine(),
|
||||
cm.highlightActiveLineGutter(),
|
||||
|
||||
let editor_container: HTMLDivElement | undefined = $state();
|
||||
let cm: any = $state(); // CodeMirror modules cache
|
||||
// Keymaps
|
||||
cm.keymap.of([
|
||||
...cm.defaultKeymap,
|
||||
...cm.searchKeymap,
|
||||
...cm.historyKeymap,
|
||||
...cm.foldKeymap,
|
||||
...cm.completionKeymap,
|
||||
...cm.lintKeymap
|
||||
]),
|
||||
|
||||
async function create_editor() {
|
||||
if (!browser) return;
|
||||
// Language Support
|
||||
language === 'markdown'
|
||||
? cm.markdown({ base: cm.markdownLanguage })
|
||||
: null,
|
||||
language === 'json'
|
||||
? cm.languages.find((l: any) => l.name === 'json')?.load()
|
||||
: null,
|
||||
|
||||
cm = await ensure_CodeMirror_modules();
|
||||
if (!cm) return;
|
||||
// Theme & Behavior
|
||||
theme_mode === 'dark' ? cm.oneDark : cm.EditorView.baseTheme(),
|
||||
cm.EditorView.contentAttributes.of({ spellcheck: 'true' }),
|
||||
readonly
|
||||
? cm.EditorState.readOnly.of(true)
|
||||
: cm.EditorView.editable.of(true),
|
||||
show_line_numbers ? cm.lineNumbers() : null,
|
||||
wrap_lines ? cm.EditorView_lineWrapping : null,
|
||||
placeholder ? cm.placeholderExt(placeholder) : null,
|
||||
|
||||
// Cleanup existing instance if HMR or remount occurs
|
||||
if (editor_view) {
|
||||
editor_view.destroy();
|
||||
editor_view = null;
|
||||
}
|
||||
// Sync back to Svelte
|
||||
cm.EditorView.updateListener.of((update: any) => {
|
||||
if (update.docChanged) {
|
||||
const doc_str = update.state.doc.toString();
|
||||
content = doc_str;
|
||||
new_content = doc_str;
|
||||
}
|
||||
})
|
||||
].filter(Boolean);
|
||||
|
||||
const extensions = [
|
||||
cm.highlightSpecialChars(),
|
||||
cm.history(),
|
||||
cm.drawSelection(),
|
||||
cm.dropCursor(),
|
||||
cm.EditorState_allowMultipleSelections.of(true),
|
||||
cm.indentOnInput(),
|
||||
cm.bracketMatching(),
|
||||
cm.closeBrackets(),
|
||||
cm.autocompletion(),
|
||||
cm.rectangularSelection(),
|
||||
cm.crosshairCursor(),
|
||||
cm.highlightActiveLine(),
|
||||
cm.highlightActiveLineGutter(),
|
||||
if (!editor_container) return;
|
||||
|
||||
// Keymaps
|
||||
cm.keymap.of([
|
||||
...cm.defaultKeymap,
|
||||
...cm.searchKeymap,
|
||||
...cm.historyKeymap,
|
||||
...cm.foldKeymap,
|
||||
...cm.completionKeymap,
|
||||
...cm.lintKeymap
|
||||
]),
|
||||
editor_view = new cm.EditorView({
|
||||
state: cm.EditorState.create({
|
||||
doc: content ?? '',
|
||||
extensions
|
||||
}),
|
||||
parent: editor_container as HTMLElement
|
||||
});
|
||||
}
|
||||
|
||||
// Language Support
|
||||
language === 'markdown' ? cm.markdown({ base: cm.markdownLanguage }) : null,
|
||||
language === 'json' ? cm.languages.find((l: any) => l.name === 'json')?.load() : null,
|
||||
// Initialize on mount
|
||||
onMount(async () => {
|
||||
await create_editor();
|
||||
});
|
||||
|
||||
// Theme & Behavior
|
||||
theme_mode === 'dark' ? cm.oneDark : cm.EditorView.baseTheme(),
|
||||
cm.EditorView.contentAttributes.of({ spellcheck: 'true' }),
|
||||
readonly ? cm.EditorState.readOnly.of(true) : cm.EditorView.editable.of(true),
|
||||
show_line_numbers ? cm.lineNumbers() : null,
|
||||
wrap_lines ? cm.EditorView_lineWrapping : null,
|
||||
placeholder ? cm.placeholderExt(placeholder) : null,
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => {
|
||||
if (editor_view) editor_view.destroy();
|
||||
});
|
||||
|
||||
// Sync back to Svelte
|
||||
cm.EditorView.updateListener.of((update: any) => {
|
||||
if (update.docChanged) {
|
||||
const doc_str = update.state.doc.toString();
|
||||
content = doc_str;
|
||||
new_content = doc_str;
|
||||
// Reactive update if content is changed from outside (e.g. API load)
|
||||
$effect(() => {
|
||||
if (editor_view && content !== editor_view.state.doc.toString()) {
|
||||
untrack(() => {
|
||||
editor_view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editor_view.state.doc.length,
|
||||
insert: content || ''
|
||||
}
|
||||
})
|
||||
].filter(Boolean);
|
||||
|
||||
if (!editor_container) return;
|
||||
|
||||
editor_view = new cm.EditorView({
|
||||
state: cm.EditorState.create({
|
||||
doc: content ?? '',
|
||||
extensions
|
||||
}),
|
||||
parent: editor_container as HTMLElement
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on mount
|
||||
onMount(async () => {
|
||||
await create_editor();
|
||||
});
|
||||
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => {
|
||||
if (editor_view) editor_view.destroy();
|
||||
});
|
||||
|
||||
// Reactive update if content is changed from outside (e.g. API load)
|
||||
$effect(() => {
|
||||
if (editor_view && content !== editor_view.state.doc.toString()) {
|
||||
untrack(() => {
|
||||
editor_view.dispatch({
|
||||
changes: { from: 0, to: editor_view.state.doc.length, insert: content || '' }
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// *** Toolbar Helpers
|
||||
const wrap_selection = (before: string, after: string = before) => {
|
||||
if (!editor_view) return;
|
||||
const state = editor_view.state;
|
||||
const changes = state.changeByRange((range: any) => {
|
||||
const is_wrapped =
|
||||
state.sliceDoc(range.from - before.length, range.from) === before &&
|
||||
state.sliceDoc(range.to, range.to + after.length) === after;
|
||||
|
||||
if (is_wrapped) {
|
||||
return {
|
||||
changes: [
|
||||
{ from: range.from - before.length, to: range.from, insert: '' },
|
||||
{ from: range.to, to: range.to + after.length, insert: '' }
|
||||
],
|
||||
range: cm.EditorSelection.range(
|
||||
range.from - before.length,
|
||||
range.to - before.length
|
||||
)
|
||||
};
|
||||
}
|
||||
// *** Toolbar Helpers
|
||||
const wrap_selection = (before: string, after: string = before) => {
|
||||
if (!editor_view) return;
|
||||
const state = editor_view.state;
|
||||
const changes = state.changeByRange((range: any) => {
|
||||
const is_wrapped =
|
||||
state.sliceDoc(range.from - before.length, range.from) === before &&
|
||||
state.sliceDoc(range.to, range.to + after.length) === after;
|
||||
|
||||
if (is_wrapped) {
|
||||
return {
|
||||
changes: [
|
||||
{ from: range.from, insert: before },
|
||||
{ from: range.to, insert: after }
|
||||
{
|
||||
from: range.from - before.length,
|
||||
to: range.from,
|
||||
insert: ''
|
||||
},
|
||||
{ from: range.to, to: range.to + after.length, insert: '' }
|
||||
],
|
||||
range: cm.EditorSelection.range(
|
||||
range.from + before.length,
|
||||
range.to + before.length
|
||||
range.from - before.length,
|
||||
range.to - before.length
|
||||
)
|
||||
};
|
||||
});
|
||||
editor_view.dispatch(changes);
|
||||
editor_view.focus();
|
||||
};
|
||||
}
|
||||
|
||||
const toggle_list = () => {
|
||||
if (!editor_view) return;
|
||||
const state = editor_view.state;
|
||||
const changes = state.changeByRange((range: any) => {
|
||||
const line = state.doc.lineAt(range.from);
|
||||
const has_list = line.text.startsWith('- ');
|
||||
return {
|
||||
changes: [
|
||||
{ from: range.from, insert: before },
|
||||
{ from: range.to, insert: after }
|
||||
],
|
||||
range: cm.EditorSelection.range(
|
||||
range.from + before.length,
|
||||
range.to + before.length
|
||||
)
|
||||
};
|
||||
});
|
||||
editor_view.dispatch(changes);
|
||||
editor_view.focus();
|
||||
};
|
||||
|
||||
if (has_list) {
|
||||
return {
|
||||
changes: [{ from: line.from, to: line.from + 2, insert: '' }],
|
||||
range: cm.EditorSelection.range(range.from - 2, range.to - 2)
|
||||
};
|
||||
}
|
||||
const toggle_list = () => {
|
||||
if (!editor_view) return;
|
||||
const state = editor_view.state;
|
||||
const changes = state.changeByRange((range: any) => {
|
||||
const line = state.doc.lineAt(range.from);
|
||||
const has_list = line.text.startsWith('- ');
|
||||
|
||||
if (has_list) {
|
||||
return {
|
||||
changes: [{ from: line.from, insert: '- ' }],
|
||||
range: cm.EditorSelection.range(range.from + 2, range.to + 2)
|
||||
changes: [{ from: line.from, to: line.from + 2, insert: '' }],
|
||||
range: cm.EditorSelection.range(range.from - 2, range.to - 2)
|
||||
};
|
||||
});
|
||||
editor_view.dispatch(changes);
|
||||
editor_view.focus();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changes: [{ from: line.from, insert: '- ' }],
|
||||
range: cm.EditorSelection.range(range.from + 2, range.to + 2)
|
||||
};
|
||||
});
|
||||
editor_view.dispatch(changes);
|
||||
editor_view.focus();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="ae__comp__codemirror_editor flex flex-col border border-surface-500/20 rounded-container overflow-hidden h-full {class_li}">
|
||||
<div
|
||||
class="ae__comp__codemirror_editor border-surface-500/20 rounded-container flex h-full flex-col overflow-hidden border {class_li}">
|
||||
{#if show_toolbar && !readonly}
|
||||
<div class="toolbar flex flex-wrap gap-1 p-1 bg-surface-50 dark:bg-surface-900 border-b border-surface-500/20">
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => wrap_selection('**')} title="Bold">
|
||||
<div
|
||||
class="toolbar bg-surface-50 dark:bg-surface-900 border-surface-500/20 flex flex-wrap gap-1 border-b p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={() => wrap_selection('**')}
|
||||
title="Bold">
|
||||
<Bold size="14" />
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => wrap_selection('*')} title="Italic">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={() => wrap_selection('*')}
|
||||
title="Italic">
|
||||
<Italic size="14" />
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={toggle_list} title="Bullet List">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={toggle_list}
|
||||
title="Bullet List">
|
||||
<List size="14" />
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => wrap_selection('`')} title="Inline Code">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={() => wrap_selection('`')}
|
||||
title="Inline Code">
|
||||
<Code size="14" />
|
||||
</button>
|
||||
|
||||
<div class="ml-auto flex gap-1">
|
||||
<span class="text-[10px] opacity-50 self-center uppercase font-bold mr-2">{language}</span>
|
||||
<span
|
||||
class="mr-2 self-center text-[10px] font-bold uppercase opacity-50"
|
||||
>{language}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -220,21 +254,21 @@
|
||||
<div
|
||||
bind:this={editor_container}
|
||||
class="grow overflow-auto bg-white dark:bg-black/20"
|
||||
style="min-height: 100px;"
|
||||
></div>
|
||||
style="min-height: 100px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.ae__comp__codemirror_editor :global(.cm-editor) {
|
||||
height: 100%;
|
||||
outline: none !important;
|
||||
}
|
||||
.ae__comp__codemirror_editor :global(.cm-scroller) {
|
||||
font-family: theme('fontFamily.mono');
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Hide the focus outline from CM6 default theme */
|
||||
.ae__comp__codemirror_editor :global(.cm-focused) {
|
||||
outline: none !important;
|
||||
}
|
||||
.ae__comp__codemirror_editor :global(.cm-editor) {
|
||||
height: 100%;
|
||||
outline: none !important;
|
||||
}
|
||||
.ae__comp__codemirror_editor :global(.cm-scroller) {
|
||||
font-family: theme('fontFamily.mono');
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Hide the focus outline from CM6 default theme */
|
||||
.ae__comp__codemirror_editor :global(.cm-focused) {
|
||||
outline: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,120 +1,163 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* element_editor_tiptap.svelte
|
||||
* Zero-Dependency Rich Text Editor for Aether Platform.
|
||||
* Uses native contenteditable to avoid TipTap/ProseMirror library conflicts.
|
||||
* Styles aligned with existing .tiptap SCSS definitions.
|
||||
*/
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { AlignLeft, Bold, Code, Italic, List, ListOrdered, RemoveFormatting, Type } from '@lucide/svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
/**
|
||||
* element_editor_tiptap.svelte
|
||||
* Zero-Dependency Rich Text Editor for Aether Platform.
|
||||
* Uses native contenteditable to avoid TipTap/ProseMirror library conflicts.
|
||||
* Styles aligned with existing .tiptap SCSS definitions.
|
||||
*/
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
AlignLeft,
|
||||
Bold,
|
||||
Code,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
RemoveFormatting,
|
||||
Type
|
||||
} from '@lucide/svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
|
||||
interface Props {
|
||||
content?: string;
|
||||
new_content?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
auto_format?: boolean;
|
||||
class_li?: string;
|
||||
interface Props {
|
||||
content?: string;
|
||||
new_content?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
auto_format?: boolean;
|
||||
class_li?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
content = $bindable(''),
|
||||
new_content = $bindable(),
|
||||
placeholder = 'Start writing...',
|
||||
readonly = false,
|
||||
auto_format = true,
|
||||
class_li = ''
|
||||
}: Props = $props();
|
||||
|
||||
let editor_element: HTMLDivElement | undefined = $state();
|
||||
let is_focused = $state(false);
|
||||
|
||||
// Sync external content changes into the editor
|
||||
$effect(() => {
|
||||
if (editor_element && content !== editor_element.innerHTML) {
|
||||
untrack(() => {
|
||||
editor_element!.innerHTML = content || '';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let {
|
||||
content = $bindable(''),
|
||||
new_content = $bindable(),
|
||||
placeholder = 'Start writing...',
|
||||
readonly = false,
|
||||
auto_format = true,
|
||||
class_li = ''
|
||||
}: Props = $props();
|
||||
function handle_input(e: Event) {
|
||||
const html = (e.target as HTMLDivElement).innerHTML;
|
||||
// Clean up empty state (browsers sometimes leave <br> or <p></p>)
|
||||
const cleaned = html === '<br>' || html === '<p></p>' ? '' : html;
|
||||
content = cleaned;
|
||||
new_content = cleaned;
|
||||
}
|
||||
|
||||
let editor_element: HTMLDivElement | undefined = $state();
|
||||
let is_focused = $state(false);
|
||||
// Toolbar Actions using native execCommand
|
||||
// (While deprecated, it remains the standard for zero-dep simple rich text)
|
||||
function exec(command: string, value: string | undefined = undefined) {
|
||||
if (!browser || readonly) return;
|
||||
document.execCommand(command, false, value);
|
||||
editor_element?.focus();
|
||||
}
|
||||
|
||||
// Sync external content changes into the editor
|
||||
$effect(() => {
|
||||
if (editor_element && content !== editor_element.innerHTML) {
|
||||
untrack(() => {
|
||||
editor_element!.innerHTML = content || '';
|
||||
});
|
||||
function handle_keydown(e: KeyboardEvent) {
|
||||
// Basic shortcuts: Cmd/Ctrl + B, I
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.key === 'b') {
|
||||
e.preventDefault();
|
||||
exec('bold');
|
||||
}
|
||||
});
|
||||
|
||||
function handle_input(e: Event) {
|
||||
const html = (e.target as HTMLDivElement).innerHTML;
|
||||
// Clean up empty state (browsers sometimes leave <br> or <p></p>)
|
||||
const cleaned = (html === '<br>' || html === '<p></p>') ? '' : html;
|
||||
content = cleaned;
|
||||
new_content = cleaned;
|
||||
}
|
||||
|
||||
// Toolbar Actions using native execCommand
|
||||
// (While deprecated, it remains the standard for zero-dep simple rich text)
|
||||
function exec(command: string, value: string | undefined = undefined) {
|
||||
if (!browser || readonly) return;
|
||||
document.execCommand(command, false, value);
|
||||
editor_element?.focus();
|
||||
}
|
||||
|
||||
function handle_keydown(e: KeyboardEvent) {
|
||||
// Basic shortcuts: Cmd/Ctrl + B, I
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.key === 'b') { e.preventDefault(); exec('bold'); }
|
||||
if (e.key === 'i') { e.preventDefault(); exec('italic'); }
|
||||
if (e.key === 'i') {
|
||||
e.preventDefault();
|
||||
exec('italic');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handle_format() {
|
||||
if (!content) return;
|
||||
const formatted = ae_util.format_html(content);
|
||||
content = formatted;
|
||||
new_content = formatted;
|
||||
}
|
||||
function handle_format() {
|
||||
if (!content) return;
|
||||
const formatted = ae_util.format_html(content);
|
||||
content = formatted;
|
||||
new_content = formatted;
|
||||
}
|
||||
|
||||
function handle_blur() {
|
||||
is_focused = false;
|
||||
if (auto_format) {
|
||||
handle_format();
|
||||
}
|
||||
function handle_blur() {
|
||||
is_focused = false;
|
||||
if (auto_format) {
|
||||
handle_format();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ae__comp__editor_tiptap flex flex-col border border-surface-500/20 rounded-container overflow-hidden bg-white dark:bg-black/10 {class_li}">
|
||||
<div
|
||||
class="ae__comp__editor_tiptap border-surface-500/20 rounded-container flex flex-col overflow-hidden border bg-white dark:bg-black/10 {class_li}">
|
||||
{#if !readonly}
|
||||
<div class="toolbar flex flex-wrap gap-1 p-1 bg-surface-50 dark:bg-surface-900 border-b border-surface-500/20">
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => exec('bold')} title="Bold">
|
||||
<div
|
||||
class="toolbar bg-surface-50 dark:bg-surface-900 border-surface-500/20 flex flex-wrap gap-1 border-b p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={() => exec('bold')}
|
||||
title="Bold">
|
||||
<Bold size="14" />
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => exec('italic')} title="Italic">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={() => exec('italic')}
|
||||
title="Italic">
|
||||
<Italic size="14" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-surface-500/20 mx-1 self-center"></div>
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => exec('insertUnorderedList')} title="Bullet List">
|
||||
<div class="bg-surface-500/20 mx-1 h-4 w-px self-center"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={() => exec('insertUnorderedList')}
|
||||
title="Bullet List">
|
||||
<List size="14" />
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => exec('insertOrderedList')} title="Numbered List">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-primary"
|
||||
onclick={() => exec('insertOrderedList')}
|
||||
title="Numbered List">
|
||||
<ListOrdered size="14" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-surface-500/20 mx-1 self-center"></div>
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-error" onclick={() => exec('removeFormat')} title="Clear Formatting">
|
||||
<div class="bg-surface-500/20 mx-1 h-4 w-px self-center"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-error"
|
||||
onclick={() => exec('removeFormat')}
|
||||
title="Clear Formatting">
|
||||
<RemoveFormatting size="14" />
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-success" onclick={handle_format} title="Format HTML Source">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft hover:variant-filled-success"
|
||||
onclick={handle_format}
|
||||
title="Format HTML Source">
|
||||
<AlignLeft size="14" />
|
||||
<span class="text-[10px] ml-1">Format</span>
|
||||
<span class="ml-1 text-[10px]">Format</span>
|
||||
</button>
|
||||
|
||||
<div class="ml-auto flex gap-1 items-center px-2">
|
||||
<div class="ml-auto flex items-center gap-1 px-2">
|
||||
<Type size="12" class="opacity-30" />
|
||||
<span class="text-[10px] opacity-50 uppercase font-bold">Visual</span>
|
||||
<span class="text-[10px] font-bold uppercase opacity-50"
|
||||
>Visual</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grow relative h-full min-h-[150px] overflow-auto p-4">
|
||||
<div class="relative h-full min-h-[150px] grow overflow-auto p-4">
|
||||
{#if !content && !is_focused}
|
||||
<div class="absolute top-4 left-4 pointer-events-none opacity-30 italic text-sm">
|
||||
<div
|
||||
class="pointer-events-none absolute top-4 left-4 text-sm italic opacity-30">
|
||||
{placeholder}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -122,33 +165,33 @@
|
||||
<div
|
||||
bind:this={editor_element}
|
||||
contenteditable={!readonly}
|
||||
class="tiptap outline-none h-full w-full prose dark:prose-invert max-w-none"
|
||||
class="tiptap prose dark:prose-invert h-full w-full max-w-none outline-none"
|
||||
oninput={handle_input}
|
||||
onfocus={() => is_focused = true}
|
||||
onfocus={() => (is_focused = true)}
|
||||
onblur={handle_blur}
|
||||
onkeydown={handle_keydown}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
aria-multiline="true"
|
||||
></div>
|
||||
aria-multiline="true">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
/* Import your existing TipTap styles */
|
||||
@import './styles/element_tiptap_editor.scss';
|
||||
/* Import your existing TipTap styles */
|
||||
@import './styles/element_tiptap_editor.scss';
|
||||
|
||||
.ae__comp__editor_tiptap :global(.tiptap) {
|
||||
min-height: 120px;
|
||||
}
|
||||
.ae__comp__editor_tiptap :global(.tiptap) {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Ensure lists look correct inside the editor */
|
||||
.ae__comp__editor_tiptap :global(.tiptap ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ae__comp__editor_tiptap :global(.tiptap ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
/* Ensure lists look correct inside the editor */
|
||||
.ae__comp__editor_tiptap :global(.tiptap ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ae__comp__editor_tiptap :global(.tiptap ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,98 +1,100 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* element_fit_text.svelte
|
||||
*
|
||||
* Wrapper component for the fit_text Svelte action. Renders a div that
|
||||
* auto-scales its font size to fill the available space (binary search).
|
||||
*
|
||||
* CRITICAL — height must be constrained for auto-scaling to work:
|
||||
* The action checks scrollHeight <= offsetHeight to detect overflow.
|
||||
* If the wrapper div has no explicit height it expands to fit content,
|
||||
* making scrollHeight == offsetHeight always — so the binary search
|
||||
* returns max font immediately (appears broken / no scaling).
|
||||
*
|
||||
* Set height via the `height` prop (CSS string, e.g. "1.5in", "3rem", "80px"),
|
||||
* OR apply a Tailwind height class via the `class` prop (e.g. "h-[1.5in]"),
|
||||
* OR let a parent flex container with a defined size control the height
|
||||
* and pass class="h-full" to fill it.
|
||||
*
|
||||
* Props:
|
||||
* min — minimum font size in px (default: 16)
|
||||
* max — maximum font size in px (default: 80)
|
||||
* manual_size — when set, disables auto-scaling and applies this font size directly
|
||||
* disabled — when true, neither auto-scaling nor manual_size is applied
|
||||
* height — explicit CSS height for the wrapper div (e.g. "1.5in", "3rem")
|
||||
* width — explicit CSS width for the wrapper div (rarely needed; usually inherits)
|
||||
* class — extra Tailwind/CSS classes for the wrapper div
|
||||
* style — extra inline styles for the wrapper div
|
||||
*
|
||||
* Example — auto-scale to fill an explicitly sized region:
|
||||
* <Element_fit_text min={20} max={80} height="1.5in" class="leading-none">
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*
|
||||
* Example — auto-scale filling a flex parent (parent must have a defined height):
|
||||
* <!-- parent: flex-1 min-h-0 overflow-hidden -->
|
||||
* <Element_fit_text min={20} max={80} class="h-full leading-none">
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*
|
||||
* Example — manual override (disables auto-scale, applies fixed size):
|
||||
* <Element_fit_text min={20} max={80} manual_size={font_size_name}>
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*/
|
||||
/**
|
||||
* element_fit_text.svelte
|
||||
*
|
||||
* Wrapper component for the fit_text Svelte action. Renders a div that
|
||||
* auto-scales its font size to fill the available space (binary search).
|
||||
*
|
||||
* CRITICAL — height must be constrained for auto-scaling to work:
|
||||
* The action checks scrollHeight <= offsetHeight to detect overflow.
|
||||
* If the wrapper div has no explicit height it expands to fit content,
|
||||
* making scrollHeight == offsetHeight always — so the binary search
|
||||
* returns max font immediately (appears broken / no scaling).
|
||||
*
|
||||
* Set height via the `height` prop (CSS string, e.g. "1.5in", "3rem", "80px"),
|
||||
* OR apply a Tailwind height class via the `class` prop (e.g. "h-[1.5in]"),
|
||||
* OR let a parent flex container with a defined size control the height
|
||||
* and pass class="h-full" to fill it.
|
||||
*
|
||||
* Props:
|
||||
* min — minimum font size in px (default: 16)
|
||||
* max — maximum font size in px (default: 80)
|
||||
* manual_size — when set, disables auto-scaling and applies this font size directly
|
||||
* disabled — when true, neither auto-scaling nor manual_size is applied
|
||||
* height — explicit CSS height for the wrapper div (e.g. "1.5in", "3rem")
|
||||
* width — explicit CSS width for the wrapper div (rarely needed; usually inherits)
|
||||
* class — extra Tailwind/CSS classes for the wrapper div
|
||||
* style — extra inline styles for the wrapper div
|
||||
*
|
||||
* Example — auto-scale to fill an explicitly sized region:
|
||||
* <Element_fit_text min={20} max={80} height="1.5in" class="leading-none">
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*
|
||||
* Example — auto-scale filling a flex parent (parent must have a defined height):
|
||||
* <!-- parent: flex-1 min-h-0 overflow-hidden -->
|
||||
* <Element_fit_text min={20} max={80} class="h-full leading-none">
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*
|
||||
* Example — manual override (disables auto-scale, applies fixed size):
|
||||
* <Element_fit_text min={20} max={80} manual_size={font_size_name}>
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*/
|
||||
|
||||
import { fit_text } from './action_fit_text';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { fit_text } from './action_fit_text';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** When set, disables auto-scaling and applies this size directly via inline style. */
|
||||
manual_size?: number | null;
|
||||
/** When true, neither auto-scaling nor manual_size is applied. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Explicit CSS height for the wrapper div (e.g. "1.5in", "80px", "3rem").
|
||||
* REQUIRED for auto-scaling unless height is controlled via class or a flex parent.
|
||||
* Without a constrained height, offsetHeight == scrollHeight always → max font returned.
|
||||
*/
|
||||
height?: string;
|
||||
/** Explicit CSS width for the wrapper div. Usually not needed — inherits from parent. */
|
||||
width?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
interface Props {
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** When set, disables auto-scaling and applies this size directly via inline style. */
|
||||
manual_size?: number | null;
|
||||
/** When true, neither auto-scaling nor manual_size is applied. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Explicit CSS height for the wrapper div (e.g. "1.5in", "80px", "3rem").
|
||||
* REQUIRED for auto-scaling unless height is controlled via class or a flex parent.
|
||||
* Without a constrained height, offsetHeight == scrollHeight always → max font returned.
|
||||
*/
|
||||
height?: string;
|
||||
/** Explicit CSS width for the wrapper div. Usually not needed — inherits from parent. */
|
||||
width?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
min = 16,
|
||||
max = 80,
|
||||
manual_size = null,
|
||||
disabled = false,
|
||||
height,
|
||||
width,
|
||||
class: extra_class = '',
|
||||
style: extra_style = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
let {
|
||||
min = 16,
|
||||
max = 80,
|
||||
manual_size = null,
|
||||
disabled = false,
|
||||
height,
|
||||
width,
|
||||
class: extra_class = '',
|
||||
style: extra_style = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
// Pass null to the action when auto-scaling should be suppressed
|
||||
let action_params = $derived(disabled || manual_size != null ? null : { min, max });
|
||||
// Pass null to the action when auto-scaling should be suppressed
|
||||
let action_params = $derived(
|
||||
disabled || manual_size != null ? null : { min, max }
|
||||
);
|
||||
|
||||
// Compose the final inline style.
|
||||
// Priority: manual_size → height/width → extra_style (caller's additional styles)
|
||||
let computed_style = $derived(() => {
|
||||
const parts: string[] = [];
|
||||
if (manual_size != null) parts.push(`font-size: ${manual_size}px`);
|
||||
if (height) parts.push(`height: ${height}`);
|
||||
if (width) parts.push(`width: ${width}`);
|
||||
if (extra_style) parts.push(extra_style);
|
||||
return parts.join('; ');
|
||||
});
|
||||
// Compose the final inline style.
|
||||
// Priority: manual_size → height/width → extra_style (caller's additional styles)
|
||||
let computed_style = $derived(() => {
|
||||
const parts: string[] = [];
|
||||
if (manual_size != null) parts.push(`font-size: ${manual_size}px`);
|
||||
if (height) parts.push(`height: ${height}`);
|
||||
if (width) parts.push(`width: ${width}`);
|
||||
if (extra_style) parts.push(extra_style);
|
||||
return parts.join('; ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={extra_class} style={computed_style()} use:fit_text={action_params}>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,370 +1,368 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { createEventDispatcher, onMount, tick } from 'svelte';
|
||||
import { createEventDispatcher, onMount, tick } from 'svelte';
|
||||
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
import { check_hosted_file_obj_w_hash } from '$lib/ae_core/core__check_hosted_file_obj_w_hash';
|
||||
import {
|
||||
ae_loc,
|
||||
ae_sess,
|
||||
ae_api,
|
||||
ae_trig,
|
||||
slct,
|
||||
slct_trigger
|
||||
} from '$lib/stores/ae_stores';
|
||||
import { LoaderCircle, Minus, Upload } from '@lucide/svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
import { check_hosted_file_obj_w_hash } from '$lib/ae_core/core__check_hosted_file_obj_w_hash';
|
||||
import {
|
||||
ae_loc,
|
||||
ae_sess,
|
||||
ae_api,
|
||||
ae_trig,
|
||||
slct,
|
||||
slct_trigger
|
||||
} from '$lib/stores/ae_stores';
|
||||
import { LoaderCircle, Minus, Upload } from '@lucide/svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface Props {
|
||||
element_id?: string;
|
||||
input_name?: string;
|
||||
container_class_li?: string[];
|
||||
input_class_li?: string[];
|
||||
table_class_li?: string[];
|
||||
multiple?: boolean;
|
||||
required?: boolean;
|
||||
accept?: string;
|
||||
untrusted_extension_list?: any;
|
||||
legacy_extension_list?: any;
|
||||
use_selected_file_table?: boolean;
|
||||
file_list_status?: null | string;
|
||||
processed_file_list?: any[];
|
||||
input_file_list?: any;
|
||||
}
|
||||
interface Props {
|
||||
element_id?: string;
|
||||
input_name?: string;
|
||||
container_class_li?: string[];
|
||||
input_class_li?: string[];
|
||||
table_class_li?: string[];
|
||||
multiple?: boolean;
|
||||
required?: boolean;
|
||||
accept?: string;
|
||||
untrusted_extension_list?: any;
|
||||
legacy_extension_list?: any;
|
||||
use_selected_file_table?: boolean;
|
||||
file_list_status?: null | string;
|
||||
processed_file_list?: any[];
|
||||
input_file_list?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
element_id = 'svelte_input_file_element',
|
||||
input_name = 'file_list',
|
||||
container_class_li = [],
|
||||
input_class_li = ['file_drop_area'],
|
||||
table_class_li = ['table', 'table-sm', 'table-striped', '', 'text-sm'],
|
||||
multiple = true,
|
||||
required = true,
|
||||
accept = 'audio/*, image/*, video/*, .bak, .cfg, .css, .csv, .doc, .docx, .gz, .htm, .html, .ini, .iso, .j2, .json, .key, .keynote, .md, .pdf, .ppt, .pptx, .rar, .rtf, .sql, .svelte, ttf, .txt, .xls, .xlsx, .xz, .zip, .bin, .dmg, .exe, .js, .msi, .php, .py, .sh',
|
||||
untrusted_extension_list = [
|
||||
'bin',
|
||||
'dmg',
|
||||
'exe',
|
||||
'js',
|
||||
'msi',
|
||||
'php',
|
||||
'py',
|
||||
'sh'
|
||||
],
|
||||
legacy_extension_list = ['avi', 'doc', 'ppt', 'xls', 'wmv'],
|
||||
use_selected_file_table = true,
|
||||
file_list_status = $bindable(null),
|
||||
processed_file_list = $bindable([]),
|
||||
input_file_list = $bindable(null)
|
||||
}: Props = $props();
|
||||
let input_file_list_processed: any[] = $state([]);
|
||||
let {
|
||||
element_id = 'svelte_input_file_element',
|
||||
input_name = 'file_list',
|
||||
container_class_li = [],
|
||||
input_class_li = ['file_drop_area'],
|
||||
table_class_li = ['table', 'table-sm', 'table-striped', '', 'text-sm'],
|
||||
multiple = true,
|
||||
required = true,
|
||||
accept = 'audio/*, image/*, video/*, .bak, .cfg, .css, .csv, .doc, .docx, .gz, .htm, .html, .ini, .iso, .j2, .json, .key, .keynote, .md, .pdf, .ppt, .pptx, .rar, .rtf, .sql, .svelte, ttf, .txt, .xls, .xlsx, .xz, .zip, .bin, .dmg, .exe, .js, .msi, .php, .py, .sh',
|
||||
untrusted_extension_list = [
|
||||
'bin',
|
||||
'dmg',
|
||||
'exe',
|
||||
'js',
|
||||
'msi',
|
||||
'php',
|
||||
'py',
|
||||
'sh'
|
||||
],
|
||||
legacy_extension_list = ['avi', 'doc', 'ppt', 'xls', 'wmv'],
|
||||
use_selected_file_table = true,
|
||||
file_list_status = $bindable(null),
|
||||
processed_file_list = $bindable([]),
|
||||
input_file_list = $bindable(null)
|
||||
}: Props = $props();
|
||||
let input_file_list_processed: any[] = $state([]);
|
||||
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element Input File');
|
||||
});
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element Input File');
|
||||
});
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
|
||||
async function process_file_list(file_list: FileList | null) {
|
||||
console.log('*** process_file_list() ***');
|
||||
async function process_file_list(file_list: FileList | null) {
|
||||
console.log('*** process_file_list() ***');
|
||||
|
||||
file_list_status = 'processing';
|
||||
processed_file_list = [];
|
||||
file_list_status = 'processing';
|
||||
processed_file_list = [];
|
||||
|
||||
if (!file_list) {
|
||||
// file_list_processed = null;
|
||||
file_list_status = 'none';
|
||||
// await tick();
|
||||
return processed_file_list;
|
||||
}
|
||||
|
||||
// const forLoop = async _ => {
|
||||
// console.log('*** Start ***');
|
||||
|
||||
// for (let [i, file_item] of file_list.entries()) { // Not sure why this does not work???
|
||||
for await (const [i, file_item] of Array.prototype.entries.call(
|
||||
file_list
|
||||
)) {
|
||||
console.log(i, file_item);
|
||||
|
||||
// NOTE: The file list is readonly. The filenames can not be changed here.
|
||||
if (
|
||||
file_item.name.endsWith('.odpmac') ||
|
||||
file_item.name.endsWith('.odpwin') ||
|
||||
file_item.name.endsWith('.pptmac') ||
|
||||
file_item.name.endsWith('.pptwin') ||
|
||||
file_item.name.endsWith('.pptxmac') ||
|
||||
file_item.name.endsWith('.pptxwin')
|
||||
) {
|
||||
console.log(
|
||||
'This file extension may need to be fixed? API upload will take care of it.'
|
||||
);
|
||||
// file_item.name = file_item.name.replace('.odpwin', '.odp');
|
||||
}
|
||||
|
||||
let file_data: key_val = {};
|
||||
|
||||
let filename = file_item.name;
|
||||
// console.log(filename);
|
||||
file_data['filename'] = filename;
|
||||
|
||||
let guessed_extension = ae_util.guess_file_extension(filename);
|
||||
file_data['guessed_extension'] = guessed_extension;
|
||||
|
||||
file_data['type'] = file_item.type;
|
||||
|
||||
let modified_date = new Date(file_item.lastModified);
|
||||
file_data['modified_date'] = modified_date;
|
||||
let modified_datetime_string = ae_util.iso_datetime_formatter(
|
||||
modified_date,
|
||||
'datetime_medium'
|
||||
);
|
||||
file_data['modified_datetime_string'] = modified_datetime_string;
|
||||
|
||||
let file_size_bytes = file_item.size;
|
||||
file_data['file_size_bytes'] = file_size_bytes;
|
||||
let file_size_string = ae_util.format_bytes(file_item.size, 2);
|
||||
file_data['file_size_string'] = file_size_string;
|
||||
|
||||
// // NOTE: Calculate the hash of the file before upload. Check if this exact file has already been uploaded.
|
||||
// let file_reader = new FileReader();
|
||||
// file_reader.onload = async function() {
|
||||
|
||||
// const hash_buffer = crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// let hash_hex_test = hash_buffer.then(async function (result_buffer) {
|
||||
// const hash_array = Array.from(new Uint8Array(result_buffer)); // convert buffer to byte array
|
||||
// const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// console.log(`File hash hex? ${hash_hex}`);
|
||||
// file_data['hash_sha256'] = hash_hex;
|
||||
// return hash_hex;
|
||||
// })
|
||||
// .catch(function (error: any) {
|
||||
// console.log('Something went wrong?', error);
|
||||
// });
|
||||
|
||||
// return hash_hex_test;
|
||||
|
||||
// // file_data['hash_hex_test'] = hash_hex_test;
|
||||
|
||||
// // const hash_buffer = await crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// // let hash_str = await new TextDecoder().decode(hash_buffer);
|
||||
// // console.log(`File hash string? ${hash_str}`);
|
||||
// // file_data['hash_str'] = hash_str;
|
||||
|
||||
// // const hash_array = Array.from(new Uint8Array(hash_buffer)); // convert buffer to byte array
|
||||
// // const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// // console.log(`File hash hex? ${hash_hex}`);
|
||||
|
||||
// // file_data['hash_sha256'] = hash_hex;
|
||||
// // return hash_hex_test;
|
||||
// // return hash_hex;
|
||||
// }
|
||||
|
||||
// // file_reader.then(function (result) {
|
||||
// // console.log(`File hash hex? ${result}`);
|
||||
// // return hash_hex;
|
||||
// // })
|
||||
// // .catch(function (error: any) {
|
||||
// // console.log('No results returned or failed.', error);
|
||||
// // });
|
||||
// // file_data['hash_sha256'] = file_reader.readAsArrayBuffer(file_item);
|
||||
|
||||
let warning_untrusted_extension = false;
|
||||
let warning_legacy_extension = false;
|
||||
let warning_size = false;
|
||||
let warning_message = null;
|
||||
|
||||
if (untrusted_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is an untrusted extension. Going to warn.');
|
||||
warning_untrusted_extension = true;
|
||||
warning_message =
|
||||
'It appears this an untrusted file type and is likely meant to be an executable or installable application. It is <strong>strongly</strong> recommended that this file not be used.';
|
||||
} else if (legacy_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is a legacy extension. Going to warn.');
|
||||
warning_legacy_extension = true;
|
||||
if (guessed_extension == 'ppt') {
|
||||
warning_message =
|
||||
'It appears this is a legacy PowerPoint file and has not been officially supported since Office PowerPoint 2003. This file is known to have issues and may not work well. It is <strong>strongly</strong> recommended that this file be saved using the modern PPTX format.';
|
||||
} else if (guessed_extension == 'avi') {
|
||||
warning_message =
|
||||
'It appears this is a video file using the AVI format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG. The file will also likely be much smaller.';
|
||||
} else if (guessed_extension == 'wmv') {
|
||||
warning_message =
|
||||
"It appears this is a video file using Microsoft's WMV format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG.";
|
||||
} else {
|
||||
warning_message =
|
||||
'It appears this is a legacy or not very well supported file format. It is <strong>strongly</strong> recommended that it be saved in an alternative format if possible.';
|
||||
}
|
||||
} else if (file_size_bytes > 52428800) {
|
||||
// 50 MB = 52428800 bytes
|
||||
// 100 MB = 104857600 bytes
|
||||
console.log(
|
||||
`This is a large file size ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes). Going to warn.`
|
||||
);
|
||||
warning_size = true;
|
||||
if (file_size_bytes > 2147483648) {
|
||||
// > 2 GB
|
||||
warning_message = `This file size (${file_size_string}) is very large and will take at <strong>least</strong> a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Most audio, image, and video files can be compressed without a significant loss in quality. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly.`;
|
||||
} else if (file_size_bytes > 209715200) {
|
||||
// > 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take at <strong>least</strong> a few minutes to upload depending on your network connection. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else if (file_size_bytes > 104857600) {
|
||||
// 100 MB to 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take a few minutes to upload depending on your network connection. Be sure you have a stable network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else {
|
||||
// 50 to 100 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and may take a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
file_data['warning_untrusted_extension'] =
|
||||
warning_untrusted_extension;
|
||||
file_data['warning_legacy_extension'] = warning_legacy_extension;
|
||||
file_data['warning_size'] = warning_size;
|
||||
|
||||
file_data['warning_message'] = warning_message;
|
||||
|
||||
file_data['uploaded'] = null;
|
||||
file_data['uploaded_bytes'] = null;
|
||||
|
||||
// input_file_list_processed.push(JSON.parse(JSON.stringify(file_data)));
|
||||
// input_file_list_processed = input_file_list_processed;
|
||||
|
||||
// console.log(get_file_hash(file_item));
|
||||
// console.log(await get_file_hash(file_item));
|
||||
|
||||
let file_hash = null;
|
||||
|
||||
// Only hash files less than 2 GB (2147483648 bytes)!!!
|
||||
console.log(
|
||||
`File size: ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes)`
|
||||
);
|
||||
if (file_size_bytes < 2000000000) {
|
||||
// > 2 GB 2 147 483 648
|
||||
file_hash = await ae_util.get_file_hash(file_item);
|
||||
} else {
|
||||
// File size in MB
|
||||
console.log(
|
||||
`File is too large to hash. File size: ${file_size_bytes / 1048576} MB`
|
||||
);
|
||||
}
|
||||
|
||||
if (file_hash) {
|
||||
console.log(
|
||||
`Found file hash to lookup: ${ae_util.shorten_string({ string: file_hash })}`
|
||||
);
|
||||
file_data['hash_sha256'] = file_hash;
|
||||
|
||||
let check_hosted_file_obj_w_hash_result =
|
||||
await check_hosted_file_obj_w_hash({
|
||||
api_cfg: $ae_api,
|
||||
hosted_file_hash: file_hash
|
||||
});
|
||||
|
||||
// console.log(check_hosted_file_obj_w_hash_result);
|
||||
|
||||
if (
|
||||
check_hosted_file_obj_w_hash_result &&
|
||||
check_hosted_file_obj_w_hash_result.hosted_file_found_check
|
||||
) {
|
||||
console.log('Matching hash!!!');
|
||||
file_data['hash_sha256_match'] = true;
|
||||
// $ae_events.pres_mgmt.new_upload_list[i].hash_sha256_match = true;
|
||||
// $ae_events = $ae_events;
|
||||
}
|
||||
} else {
|
||||
file_data['hash_sha256'] = null;
|
||||
file_data['hash_sha256_match'] = false;
|
||||
}
|
||||
|
||||
processed_file_list.push(file_data);
|
||||
// input_file_list_processed.push(file_data);
|
||||
}
|
||||
|
||||
file_list_status = 'ready';
|
||||
console.log(processed_file_list);
|
||||
|
||||
// return JSON.parse(JSON.stringify(processed_file_list));
|
||||
if (!file_list) {
|
||||
// file_list_processed = null;
|
||||
file_list_status = 'none';
|
||||
// await tick();
|
||||
return processed_file_list;
|
||||
}
|
||||
|
||||
function remove_file_from_filelist(index: number) {
|
||||
console.log('*** remove_file_from_filelist() ***');
|
||||
// const forLoop = async _ => {
|
||||
// console.log('*** Start ***');
|
||||
|
||||
// Can not use something like this because it is readonly:
|
||||
const dt = new DataTransfer();
|
||||
// let input = document.getElementById(input_element_id);
|
||||
// for (let [i, file_item] of file_list.entries()) { // Not sure why this does not work???
|
||||
for await (const [i, file_item] of Array.prototype.entries.call(
|
||||
file_list
|
||||
)) {
|
||||
console.log(i, file_item);
|
||||
|
||||
let input_element = document.querySelector(
|
||||
'input[type="file"].svelte_input_file_element'
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (!input_element) {
|
||||
console.error('Could not find the input element.');
|
||||
return false;
|
||||
// NOTE: The file list is readonly. The filenames can not be changed here.
|
||||
if (
|
||||
file_item.name.endsWith('.odpmac') ||
|
||||
file_item.name.endsWith('.odpwin') ||
|
||||
file_item.name.endsWith('.pptmac') ||
|
||||
file_item.name.endsWith('.pptwin') ||
|
||||
file_item.name.endsWith('.pptxmac') ||
|
||||
file_item.name.endsWith('.pptxwin')
|
||||
) {
|
||||
console.log(
|
||||
'This file extension may need to be fixed? API upload will take care of it.'
|
||||
);
|
||||
// file_item.name = file_item.name.replace('.odpwin', '.odp');
|
||||
}
|
||||
|
||||
let files = input_file_list;
|
||||
let file_data: key_val = {};
|
||||
|
||||
if (!files || !files.length) {
|
||||
console.error('No files found in the file list.');
|
||||
file_list_status = null;
|
||||
return false;
|
||||
let filename = file_item.name;
|
||||
// console.log(filename);
|
||||
file_data['filename'] = filename;
|
||||
|
||||
let guessed_extension = ae_util.guess_file_extension(filename);
|
||||
file_data['guessed_extension'] = guessed_extension;
|
||||
|
||||
file_data['type'] = file_item.type;
|
||||
|
||||
let modified_date = new Date(file_item.lastModified);
|
||||
file_data['modified_date'] = modified_date;
|
||||
let modified_datetime_string = ae_util.iso_datetime_formatter(
|
||||
modified_date,
|
||||
'datetime_medium'
|
||||
);
|
||||
file_data['modified_datetime_string'] = modified_datetime_string;
|
||||
|
||||
let file_size_bytes = file_item.size;
|
||||
file_data['file_size_bytes'] = file_size_bytes;
|
||||
let file_size_string = ae_util.format_bytes(file_item.size, 2);
|
||||
file_data['file_size_string'] = file_size_string;
|
||||
|
||||
// // NOTE: Calculate the hash of the file before upload. Check if this exact file has already been uploaded.
|
||||
// let file_reader = new FileReader();
|
||||
// file_reader.onload = async function() {
|
||||
|
||||
// const hash_buffer = crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// let hash_hex_test = hash_buffer.then(async function (result_buffer) {
|
||||
// const hash_array = Array.from(new Uint8Array(result_buffer)); // convert buffer to byte array
|
||||
// const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// console.log(`File hash hex? ${hash_hex}`);
|
||||
// file_data['hash_sha256'] = hash_hex;
|
||||
// return hash_hex;
|
||||
// })
|
||||
// .catch(function (error: any) {
|
||||
// console.log('Something went wrong?', error);
|
||||
// });
|
||||
|
||||
// return hash_hex_test;
|
||||
|
||||
// // file_data['hash_hex_test'] = hash_hex_test;
|
||||
|
||||
// // const hash_buffer = await crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// // let hash_str = await new TextDecoder().decode(hash_buffer);
|
||||
// // console.log(`File hash string? ${hash_str}`);
|
||||
// // file_data['hash_str'] = hash_str;
|
||||
|
||||
// // const hash_array = Array.from(new Uint8Array(hash_buffer)); // convert buffer to byte array
|
||||
// // const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// // console.log(`File hash hex? ${hash_hex}`);
|
||||
|
||||
// // file_data['hash_sha256'] = hash_hex;
|
||||
// // return hash_hex_test;
|
||||
// // return hash_hex;
|
||||
// }
|
||||
|
||||
// // file_reader.then(function (result) {
|
||||
// // console.log(`File hash hex? ${result}`);
|
||||
// // return hash_hex;
|
||||
// // })
|
||||
// // .catch(function (error: any) {
|
||||
// // console.log('No results returned or failed.', error);
|
||||
// // });
|
||||
// // file_data['hash_sha256'] = file_reader.readAsArrayBuffer(file_item);
|
||||
|
||||
let warning_untrusted_extension = false;
|
||||
let warning_legacy_extension = false;
|
||||
let warning_size = false;
|
||||
let warning_message = null;
|
||||
|
||||
if (untrusted_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is an untrusted extension. Going to warn.');
|
||||
warning_untrusted_extension = true;
|
||||
warning_message =
|
||||
'It appears this an untrusted file type and is likely meant to be an executable or installable application. It is <strong>strongly</strong> recommended that this file not be used.';
|
||||
} else if (legacy_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is a legacy extension. Going to warn.');
|
||||
warning_legacy_extension = true;
|
||||
if (guessed_extension == 'ppt') {
|
||||
warning_message =
|
||||
'It appears this is a legacy PowerPoint file and has not been officially supported since Office PowerPoint 2003. This file is known to have issues and may not work well. It is <strong>strongly</strong> recommended that this file be saved using the modern PPTX format.';
|
||||
} else if (guessed_extension == 'avi') {
|
||||
warning_message =
|
||||
'It appears this is a video file using the AVI format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG. The file will also likely be much smaller.';
|
||||
} else if (guessed_extension == 'wmv') {
|
||||
warning_message =
|
||||
"It appears this is a video file using Microsoft's WMV format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG.";
|
||||
} else {
|
||||
warning_message =
|
||||
'It appears this is a legacy or not very well supported file format. It is <strong>strongly</strong> recommended that it be saved in an alternative format if possible.';
|
||||
}
|
||||
} else if (file_size_bytes > 52428800) {
|
||||
// 50 MB = 52428800 bytes
|
||||
// 100 MB = 104857600 bytes
|
||||
console.log(
|
||||
`This is a large file size ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes). Going to warn.`
|
||||
);
|
||||
warning_size = true;
|
||||
if (file_size_bytes > 2147483648) {
|
||||
// > 2 GB
|
||||
warning_message = `This file size (${file_size_string}) is very large and will take at <strong>least</strong> a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Most audio, image, and video files can be compressed without a significant loss in quality. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly.`;
|
||||
} else if (file_size_bytes > 209715200) {
|
||||
// > 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take at <strong>least</strong> a few minutes to upload depending on your network connection. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else if (file_size_bytes > 104857600) {
|
||||
// 100 MB to 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take a few minutes to upload depending on your network connection. Be sure you have a stable network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else {
|
||||
// 50 to 100 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and may take a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (index !== i)
|
||||
// Only include the file if it does not match the index value.
|
||||
dt.items.add(file);
|
||||
file_data['warning_untrusted_extension'] = warning_untrusted_extension;
|
||||
file_data['warning_legacy_extension'] = warning_legacy_extension;
|
||||
file_data['warning_size'] = warning_size;
|
||||
|
||||
file_data['warning_message'] = warning_message;
|
||||
|
||||
file_data['uploaded'] = null;
|
||||
file_data['uploaded_bytes'] = null;
|
||||
|
||||
// input_file_list_processed.push(JSON.parse(JSON.stringify(file_data)));
|
||||
// input_file_list_processed = input_file_list_processed;
|
||||
|
||||
// console.log(get_file_hash(file_item));
|
||||
// console.log(await get_file_hash(file_item));
|
||||
|
||||
let file_hash = null;
|
||||
|
||||
// Only hash files less than 2 GB (2147483648 bytes)!!!
|
||||
console.log(
|
||||
`File size: ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes)`
|
||||
);
|
||||
if (file_size_bytes < 2000000000) {
|
||||
// > 2 GB 2 147 483 648
|
||||
file_hash = await ae_util.get_file_hash(file_item);
|
||||
} else {
|
||||
// File size in MB
|
||||
console.log(
|
||||
`File is too large to hash. File size: ${file_size_bytes / 1048576} MB`
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: I thought just setting the input_element.files OR input_file_list would trigger the input_file_list change.
|
||||
// input_element.files = Object.assign({}, dt.files);
|
||||
input_element.files = dt.files; // Assign the updates list
|
||||
// input_file_list = null;
|
||||
input_file_list = dt.files; // I feel like this should not need to be done, but doing it anyways.
|
||||
if (file_hash) {
|
||||
console.log(
|
||||
`Found file hash to lookup: ${ae_util.shorten_string({ string: file_hash })}`
|
||||
);
|
||||
file_data['hash_sha256'] = file_hash;
|
||||
|
||||
return true;
|
||||
}
|
||||
run(() => {
|
||||
if (input_file_list) {
|
||||
console.log(input_file_list);
|
||||
|
||||
process_file_list(input_file_list).then(function (result) {
|
||||
// console.log(result);
|
||||
|
||||
if (!result || !result.length) {
|
||||
file_list_status = 'none';
|
||||
}
|
||||
|
||||
// Save the results to the file upload list to be displayed as a table.
|
||||
input_file_list_processed = result; // Includes file hash
|
||||
|
||||
dispatch('input_file_list_updated', {
|
||||
element_id: element_id,
|
||||
input_file_list: input_file_list,
|
||||
input_file_list_processed: result // Includes file hash
|
||||
let check_hosted_file_obj_w_hash_result =
|
||||
await check_hosted_file_obj_w_hash({
|
||||
api_cfg: $ae_api,
|
||||
hosted_file_hash: file_hash
|
||||
});
|
||||
});
|
||||
|
||||
// console.log(check_hosted_file_obj_w_hash_result);
|
||||
|
||||
if (
|
||||
check_hosted_file_obj_w_hash_result &&
|
||||
check_hosted_file_obj_w_hash_result.hosted_file_found_check
|
||||
) {
|
||||
console.log('Matching hash!!!');
|
||||
file_data['hash_sha256_match'] = true;
|
||||
// $ae_events.pres_mgmt.new_upload_list[i].hash_sha256_match = true;
|
||||
// $ae_events = $ae_events;
|
||||
}
|
||||
} else {
|
||||
file_data['hash_sha256'] = null;
|
||||
file_data['hash_sha256_match'] = false;
|
||||
}
|
||||
});
|
||||
|
||||
processed_file_list.push(file_data);
|
||||
// input_file_list_processed.push(file_data);
|
||||
}
|
||||
|
||||
file_list_status = 'ready';
|
||||
console.log(processed_file_list);
|
||||
|
||||
// return JSON.parse(JSON.stringify(processed_file_list));
|
||||
return processed_file_list;
|
||||
}
|
||||
|
||||
function remove_file_from_filelist(index: number) {
|
||||
console.log('*** remove_file_from_filelist() ***');
|
||||
|
||||
// Can not use something like this because it is readonly:
|
||||
const dt = new DataTransfer();
|
||||
// let input = document.getElementById(input_element_id);
|
||||
|
||||
let input_element = document.querySelector(
|
||||
'input[type="file"].svelte_input_file_element'
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (!input_element) {
|
||||
console.error('Could not find the input element.');
|
||||
return false;
|
||||
}
|
||||
|
||||
let files = input_file_list;
|
||||
|
||||
if (!files || !files.length) {
|
||||
console.error('No files found in the file list.');
|
||||
file_list_status = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (index !== i)
|
||||
// Only include the file if it does not match the index value.
|
||||
dt.items.add(file);
|
||||
}
|
||||
|
||||
// NOTE: I thought just setting the input_element.files OR input_file_list would trigger the input_file_list change.
|
||||
// input_element.files = Object.assign({}, dt.files);
|
||||
input_element.files = dt.files; // Assign the updates list
|
||||
// input_file_list = null;
|
||||
input_file_list = dt.files; // I feel like this should not need to be done, but doing it anyways.
|
||||
|
||||
return true;
|
||||
}
|
||||
run(() => {
|
||||
if (input_file_list) {
|
||||
console.log(input_file_list);
|
||||
|
||||
process_file_list(input_file_list).then(function (result) {
|
||||
// console.log(result);
|
||||
|
||||
if (!result || !result.length) {
|
||||
file_list_status = 'none';
|
||||
}
|
||||
|
||||
// Save the results to the file upload list to be displayed as a table.
|
||||
input_file_list_processed = result; // Includes file hash
|
||||
|
||||
dispatch('input_file_list_updated', {
|
||||
element_id: element_id,
|
||||
input_file_list: input_file_list,
|
||||
input_file_list_processed: result // Includes file hash
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="svelte_element ae_element ae_input_file flex flex-col gap-1 items-center justify-center {container_class_li.join(
|
||||
class="svelte_element ae_element ae_input_file flex flex-col items-center justify-center gap-1 {container_class_li.join(
|
||||
' '
|
||||
)} text-center"
|
||||
>
|
||||
)} text-center">
|
||||
<label for={element_id} class="svelte_input_file_label text-center">
|
||||
<div>
|
||||
<Upload size="1em" />
|
||||
@@ -387,15 +385,13 @@
|
||||
{required}
|
||||
{accept}
|
||||
name={input_name}
|
||||
class="svelte_input_file_element {input_class_li.join(' ')}"
|
||||
/>
|
||||
class="svelte_input_file_element {input_class_li.join(' ')}" />
|
||||
|
||||
{#if file_list_status == 'processing'}
|
||||
<div
|
||||
class="file_list_status ae_warning preset-tonal-warning border border-warning-500 p-1 m-1"
|
||||
>
|
||||
<LoaderCircle size="1em" class="m-1 animate-spin" /> Processing selected file
|
||||
list...
|
||||
class="file_list_status ae_warning preset-tonal-warning border-warning-500 m-1 border p-1">
|
||||
<LoaderCircle size="1em" class="m-1 animate-spin" /> Processing selected
|
||||
file list...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -424,20 +420,17 @@
|
||||
remove_file_from_filelist(file_index);
|
||||
})}
|
||||
class="btn btn-md preset-tonal-warning hover:preset-filled-secondary-500 m-1"
|
||||
title="Remove file from upload list"
|
||||
>
|
||||
title="Remove file from upload list">
|
||||
<Minus size="1em" />
|
||||
<span class="hidden">Remove</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="file_filename">{file_list_item.filename}</td>
|
||||
<td class="file_last_modified"
|
||||
>{file_list_item.modified_datetime_string}</td
|
||||
>
|
||||
>{file_list_item.modified_datetime_string}</td>
|
||||
<td
|
||||
class="file_size"
|
||||
class:bg-pink-200={file_list_item.warning_size}
|
||||
>
|
||||
class:bg-pink-200={file_list_item.warning_size}>
|
||||
{file_list_item.file_size_string}
|
||||
<!-- {#if $ae_sess.api_upload_kv[link_to_id]}
|
||||
<span class="text-xs">({$ae_sess.api_upload_kv[link_to_id].percent_completed}%)</span>
|
||||
@@ -447,14 +440,12 @@
|
||||
<td
|
||||
class="file_extension"
|
||||
class:bg-red-200={file_list_item.warning_untrusted_extension}
|
||||
class:bg-pink-200={file_list_item.warning_legacy_extension}
|
||||
>
|
||||
class:bg-pink-200={file_list_item.warning_legacy_extension}>
|
||||
{file_list_item.guessed_extension}
|
||||
</td>
|
||||
<td
|
||||
class="file_hash file_hash256"
|
||||
class:bg-pink-200={file_list_item.hash_sha256_match}
|
||||
>
|
||||
class:bg-pink-200={file_list_item.hash_sha256_match}>
|
||||
{ae_util.shorten_string({
|
||||
string: file_list_item.hash_sha256,
|
||||
begin_length: 5,
|
||||
@@ -470,18 +461,18 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
th {
|
||||
text-align: center;
|
||||
/* font-size: smaller; */
|
||||
}
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
.file_last_modified {
|
||||
font-size: smaller;
|
||||
}
|
||||
th {
|
||||
text-align: center;
|
||||
/* font-size: smaller; */
|
||||
}
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
.file_last_modified {
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.file_hash {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
.file_hash {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,341 +1,363 @@
|
||||
<script lang="ts">
|
||||
import { run, preventDefault } from 'svelte/legacy';
|
||||
import { run, preventDefault } from 'svelte/legacy';
|
||||
|
||||
import { createEventDispatcher, onMount, tick } from 'svelte';
|
||||
import { createEventDispatcher, onMount, tick } from 'svelte';
|
||||
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
import { check_hosted_file_obj_w_hash } from '$lib/ae_core/core__check_hosted_file_obj_w_hash';
|
||||
import { ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/stores/ae_stores';
|
||||
import { LoaderCircle, Minus } from '@lucide/svelte';
|
||||
interface Props {
|
||||
// export let element_id = 'svelte_input_file_element';
|
||||
container_class_li?: string[];
|
||||
table_class_li?: string[];
|
||||
untrusted_extension_list?: any;
|
||||
legacy_extension_list?: any;
|
||||
use_selected_file_table?: boolean;
|
||||
input_file_list?: any;
|
||||
file_list_status?: null | string;
|
||||
processed_file_list?: any[];
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
import { check_hosted_file_obj_w_hash } from '$lib/ae_core/core__check_hosted_file_obj_w_hash';
|
||||
import {
|
||||
ae_loc,
|
||||
ae_sess,
|
||||
ae_api,
|
||||
ae_trig,
|
||||
slct,
|
||||
slct_trigger
|
||||
} from '$lib/stores/ae_stores';
|
||||
import { LoaderCircle, Minus } from '@lucide/svelte';
|
||||
interface Props {
|
||||
// export let element_id = 'svelte_input_file_element';
|
||||
container_class_li?: string[];
|
||||
table_class_li?: string[];
|
||||
untrusted_extension_list?: any;
|
||||
legacy_extension_list?: any;
|
||||
use_selected_file_table?: boolean;
|
||||
input_file_list?: any;
|
||||
file_list_status?: null | string;
|
||||
processed_file_list?: any[];
|
||||
}
|
||||
|
||||
let {
|
||||
container_class_li = [],
|
||||
table_class_li = ['table', 'table-sm', 'table-striped', '', 'text-sm'],
|
||||
untrusted_extension_list = [
|
||||
'bin',
|
||||
'dmg',
|
||||
'exe',
|
||||
'js',
|
||||
'msi',
|
||||
'php',
|
||||
'py',
|
||||
'sh'
|
||||
],
|
||||
legacy_extension_list = ['avi', 'doc', 'ppt', 'xls', 'wmv'],
|
||||
use_selected_file_table = true,
|
||||
input_file_list = $bindable(null),
|
||||
file_list_status = $bindable(null),
|
||||
processed_file_list = $bindable([])
|
||||
}: Props = $props();
|
||||
|
||||
// const dispatch = createEventDispatcher();
|
||||
|
||||
// let input_file_list_processed: any[] = [];
|
||||
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element Input File');
|
||||
});
|
||||
|
||||
async function process_file_list(file_list: FileList) {
|
||||
console.log('*** process_file_list() ***');
|
||||
|
||||
file_list_status = 'processing';
|
||||
processed_file_list = [];
|
||||
|
||||
if (!file_list) {
|
||||
// file_list_processed = null;
|
||||
file_list_status = 'none';
|
||||
// await tick();
|
||||
return processed_file_list;
|
||||
}
|
||||
|
||||
let {
|
||||
container_class_li = [],
|
||||
table_class_li = ['table', 'table-sm', 'table-striped', '', 'text-sm'],
|
||||
untrusted_extension_list = ['bin', 'dmg', 'exe', 'js', 'msi', 'php', 'py', 'sh'],
|
||||
legacy_extension_list = ['avi', 'doc', 'ppt', 'xls', 'wmv'],
|
||||
use_selected_file_table = true,
|
||||
input_file_list = $bindable(null),
|
||||
file_list_status = $bindable(null),
|
||||
processed_file_list = $bindable([])
|
||||
}: Props = $props();
|
||||
// const forLoop = async _ => {
|
||||
// console.log('*** Start ***');
|
||||
|
||||
// const dispatch = createEventDispatcher();
|
||||
// for (let [i, file_item] of file_list.entries()) { // Not sure why this does not work???
|
||||
for await (const [i, file_item] of Array.prototype.entries.call(
|
||||
file_list
|
||||
)) {
|
||||
console.log(i, file_item);
|
||||
|
||||
// let input_file_list_processed: any[] = [];
|
||||
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element Input File');
|
||||
});
|
||||
|
||||
async function process_file_list(file_list: FileList) {
|
||||
console.log('*** process_file_list() ***');
|
||||
|
||||
file_list_status = 'processing';
|
||||
processed_file_list = [];
|
||||
|
||||
if (!file_list) {
|
||||
// file_list_processed = null;
|
||||
file_list_status = 'none';
|
||||
// await tick();
|
||||
return processed_file_list;
|
||||
// NOTE: The file list is readonly. The filenames can not be changed here.
|
||||
if (
|
||||
file_item.name.endsWith('.odpmac') ||
|
||||
file_item.name.endsWith('.odpwin') ||
|
||||
file_item.name.endsWith('.pptmac') ||
|
||||
file_item.name.endsWith('.pptwin') ||
|
||||
file_item.name.endsWith('.pptxmac') ||
|
||||
file_item.name.endsWith('.pptxwin')
|
||||
) {
|
||||
console.log(
|
||||
'This file extension may need to be fixed? API upload will take care of it.'
|
||||
);
|
||||
// file_item.name = file_item.name.replace('.odpwin', '.odp');
|
||||
}
|
||||
|
||||
// const forLoop = async _ => {
|
||||
// console.log('*** Start ***');
|
||||
let file_data: key_val = {};
|
||||
|
||||
// for (let [i, file_item] of file_list.entries()) { // Not sure why this does not work???
|
||||
for await (const [i, file_item] of Array.prototype.entries.call(file_list)) {
|
||||
console.log(i, file_item);
|
||||
let filename = file_item.name;
|
||||
// console.log(filename);
|
||||
file_data['filename'] = filename;
|
||||
|
||||
// NOTE: The file list is readonly. The filenames can not be changed here.
|
||||
if (
|
||||
file_item.name.endsWith('.odpmac') ||
|
||||
file_item.name.endsWith('.odpwin') ||
|
||||
file_item.name.endsWith('.pptmac') ||
|
||||
file_item.name.endsWith('.pptwin') ||
|
||||
file_item.name.endsWith('.pptxmac') ||
|
||||
file_item.name.endsWith('.pptxwin')
|
||||
) {
|
||||
console.log(
|
||||
'This file extension may need to be fixed? API upload will take care of it.'
|
||||
);
|
||||
// file_item.name = file_item.name.replace('.odpwin', '.odp');
|
||||
}
|
||||
let guessed_extension = ae_util.guess_file_extension(filename);
|
||||
file_data['guessed_extension'] = guessed_extension;
|
||||
|
||||
let file_data: key_val = {};
|
||||
file_data['type'] = file_item.type;
|
||||
|
||||
let filename = file_item.name;
|
||||
// console.log(filename);
|
||||
file_data['filename'] = filename;
|
||||
let modified_date = new Date(file_item.lastModified);
|
||||
file_data['modified_date'] = modified_date;
|
||||
let modified_datetime_string = ae_util.iso_datetime_formatter(
|
||||
modified_date,
|
||||
'datetime_medium'
|
||||
);
|
||||
file_data['modified_datetime_string'] = modified_datetime_string;
|
||||
|
||||
let guessed_extension = ae_util.guess_file_extension(filename);
|
||||
file_data['guessed_extension'] = guessed_extension;
|
||||
let file_size_bytes = file_item.size;
|
||||
file_data['file_size_bytes'] = file_size_bytes;
|
||||
let file_size_string = ae_util.format_bytes(file_item.size, 2);
|
||||
file_data['file_size_string'] = file_size_string;
|
||||
|
||||
file_data['type'] = file_item.type;
|
||||
// // NOTE: Calculate the hash of the file before upload. Check if this exact file has already been uploaded.
|
||||
// let file_reader = new FileReader();
|
||||
// file_reader.onload = async function() {
|
||||
|
||||
let modified_date = new Date(file_item.lastModified);
|
||||
file_data['modified_date'] = modified_date;
|
||||
let modified_datetime_string = ae_util.iso_datetime_formatter(
|
||||
modified_date,
|
||||
'datetime_medium'
|
||||
);
|
||||
file_data['modified_datetime_string'] = modified_datetime_string;
|
||||
// const hash_buffer = crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// let hash_hex_test = hash_buffer.then(async function (result_buffer) {
|
||||
// const hash_array = Array.from(new Uint8Array(result_buffer)); // convert buffer to byte array
|
||||
// const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// console.log(`File hash hex? ${hash_hex}`);
|
||||
// file_data['hash_sha256'] = hash_hex;
|
||||
// return hash_hex;
|
||||
// })
|
||||
// .catch(function (error: any) {
|
||||
// console.log('Something went wrong?', error);
|
||||
// });
|
||||
|
||||
let file_size_bytes = file_item.size;
|
||||
file_data['file_size_bytes'] = file_size_bytes;
|
||||
let file_size_string = ae_util.format_bytes(file_item.size, 2);
|
||||
file_data['file_size_string'] = file_size_string;
|
||||
// return hash_hex_test;
|
||||
|
||||
// // NOTE: Calculate the hash of the file before upload. Check if this exact file has already been uploaded.
|
||||
// let file_reader = new FileReader();
|
||||
// file_reader.onload = async function() {
|
||||
// // file_data['hash_hex_test'] = hash_hex_test;
|
||||
|
||||
// const hash_buffer = crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// let hash_hex_test = hash_buffer.then(async function (result_buffer) {
|
||||
// const hash_array = Array.from(new Uint8Array(result_buffer)); // convert buffer to byte array
|
||||
// const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// console.log(`File hash hex? ${hash_hex}`);
|
||||
// file_data['hash_sha256'] = hash_hex;
|
||||
// return hash_hex;
|
||||
// })
|
||||
// .catch(function (error: any) {
|
||||
// console.log('Something went wrong?', error);
|
||||
// });
|
||||
// // const hash_buffer = await crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// // let hash_str = await new TextDecoder().decode(hash_buffer);
|
||||
// // console.log(`File hash string? ${hash_str}`);
|
||||
// // file_data['hash_str'] = hash_str;
|
||||
|
||||
// return hash_hex_test;
|
||||
// // const hash_array = Array.from(new Uint8Array(hash_buffer)); // convert buffer to byte array
|
||||
// // const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// // console.log(`File hash hex? ${hash_hex}`);
|
||||
|
||||
// // file_data['hash_hex_test'] = hash_hex_test;
|
||||
// // file_data['hash_sha256'] = hash_hex;
|
||||
// // return hash_hex_test;
|
||||
// // return hash_hex;
|
||||
// }
|
||||
|
||||
// // const hash_buffer = await crypto.subtle.digest('SHA-256', file_reader.result);
|
||||
// // let hash_str = await new TextDecoder().decode(hash_buffer);
|
||||
// // console.log(`File hash string? ${hash_str}`);
|
||||
// // file_data['hash_str'] = hash_str;
|
||||
// // file_reader.then(function (result) {
|
||||
// // console.log(`File hash hex? ${result}`);
|
||||
// // return hash_hex;
|
||||
// // })
|
||||
// // .catch(function (error: any) {
|
||||
// // console.log('No results returned or failed.', error);
|
||||
// // });
|
||||
// // file_data['hash_sha256'] = file_reader.readAsArrayBuffer(file_item);
|
||||
|
||||
// // const hash_array = Array.from(new Uint8Array(hash_buffer)); // convert buffer to byte array
|
||||
// // const hash_hex = hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// // console.log(`File hash hex? ${hash_hex}`);
|
||||
let warning_untrusted_extension = false;
|
||||
let warning_legacy_extension = false;
|
||||
let warning_size = false;
|
||||
let warning_message = null;
|
||||
|
||||
// // file_data['hash_sha256'] = hash_hex;
|
||||
// // return hash_hex_test;
|
||||
// // return hash_hex;
|
||||
// }
|
||||
|
||||
// // file_reader.then(function (result) {
|
||||
// // console.log(`File hash hex? ${result}`);
|
||||
// // return hash_hex;
|
||||
// // })
|
||||
// // .catch(function (error: any) {
|
||||
// // console.log('No results returned or failed.', error);
|
||||
// // });
|
||||
// // file_data['hash_sha256'] = file_reader.readAsArrayBuffer(file_item);
|
||||
|
||||
let warning_untrusted_extension = false;
|
||||
let warning_legacy_extension = false;
|
||||
let warning_size = false;
|
||||
let warning_message = null;
|
||||
|
||||
if (untrusted_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is an untrusted extension. Going to warn.');
|
||||
warning_untrusted_extension = true;
|
||||
if (untrusted_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is an untrusted extension. Going to warn.');
|
||||
warning_untrusted_extension = true;
|
||||
warning_message =
|
||||
'It appears this an untrusted file type and is likely meant to be an executable or installable application. It is <strong>strongly</strong> recommended that this file not be used.';
|
||||
} else if (legacy_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is a legacy extension. Going to warn.');
|
||||
warning_legacy_extension = true;
|
||||
if (guessed_extension == 'ppt') {
|
||||
warning_message =
|
||||
'It appears this an untrusted file type and is likely meant to be an executable or installable application. It is <strong>strongly</strong> recommended that this file not be used.';
|
||||
} else if (legacy_extension_list.includes(guessed_extension)) {
|
||||
console.log('This is a legacy extension. Going to warn.');
|
||||
warning_legacy_extension = true;
|
||||
if (guessed_extension == 'ppt') {
|
||||
warning_message =
|
||||
'It appears this is a legacy PowerPoint file and has not been officially supported since Office PowerPoint 2003. This file is known to have issues and may not work well. It is <strong>strongly</strong> recommended that this file be saved using the modern PPTX format.';
|
||||
} else if (guessed_extension == 'avi') {
|
||||
warning_message =
|
||||
'It appears this is a video file using the AVI format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG. The file will also likely be much smaller.';
|
||||
} else if (guessed_extension == 'wmv') {
|
||||
warning_message =
|
||||
"It appears this is a video file using Microsoft's WMV format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG.";
|
||||
} else {
|
||||
warning_message =
|
||||
'It appears this is a legacy or not very well supported file format. It is <strong>strongly</strong> recommended that it be saved in an alternative format if possible.';
|
||||
}
|
||||
} else if (file_size_bytes > 52428800) {
|
||||
// 50 MB = 52428800 bytes
|
||||
// 100 MB = 104857600 bytes
|
||||
console.log(
|
||||
`This is a large file size ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes). Going to warn.`
|
||||
);
|
||||
warning_size = true;
|
||||
if (file_size_bytes > 2147483648) {
|
||||
// > 2 GB
|
||||
warning_message = `This file size (${file_size_string}) is very large and will take at <strong>least</strong> a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Most audio, image, and video files can be compressed without a significant loss in quality. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly.`;
|
||||
} else if (file_size_bytes > 209715200) {
|
||||
// > 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take at <strong>least</strong> a few minutes to upload depending on your network connection. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else if (file_size_bytes > 104857600) {
|
||||
// 100 MB to 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take a few minutes to upload depending on your network connection. Be sure you have a stable network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else {
|
||||
// 50 to 100 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and may take a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
}
|
||||
'It appears this is a legacy PowerPoint file and has not been officially supported since Office PowerPoint 2003. This file is known to have issues and may not work well. It is <strong>strongly</strong> recommended that this file be saved using the modern PPTX format.';
|
||||
} else if (guessed_extension == 'avi') {
|
||||
warning_message =
|
||||
'It appears this is a video file using the AVI format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG. The file will also likely be much smaller.';
|
||||
} else if (guessed_extension == 'wmv') {
|
||||
warning_message =
|
||||
"It appears this is a video file using Microsoft's WMV format. It is <strong>strongly</strong> recommended that this file be re-saved as an MP4, MOV, MKV, or MPG/MPEG.";
|
||||
} else {
|
||||
warning_message =
|
||||
'It appears this is a legacy or not very well supported file format. It is <strong>strongly</strong> recommended that it be saved in an alternative format if possible.';
|
||||
}
|
||||
|
||||
file_data['warning_untrusted_extension'] = warning_untrusted_extension;
|
||||
file_data['warning_legacy_extension'] = warning_legacy_extension;
|
||||
file_data['warning_size'] = warning_size;
|
||||
|
||||
file_data['warning_message'] = warning_message;
|
||||
|
||||
file_data['uploaded'] = null;
|
||||
file_data['uploaded_bytes'] = null;
|
||||
|
||||
// input_file_list_processed.push(JSON.parse(JSON.stringify(file_data)));
|
||||
// input_file_list_processed = input_file_list_processed;
|
||||
|
||||
// console.log(get_file_hash(file_item));
|
||||
// console.log(await get_file_hash(file_item));
|
||||
|
||||
let file_hash = null;
|
||||
|
||||
// Only hash files less than 2 GB (2147483648 bytes)!!!
|
||||
console.log(`File size: ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes)`);
|
||||
if (file_size_bytes < 2000000000) {
|
||||
// > 2 GB 2 147 483 648
|
||||
file_hash = await ae_util.get_file_hash(file_item);
|
||||
} else if (file_size_bytes > 52428800) {
|
||||
// 50 MB = 52428800 bytes
|
||||
// 100 MB = 104857600 bytes
|
||||
console.log(
|
||||
`This is a large file size ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes). Going to warn.`
|
||||
);
|
||||
warning_size = true;
|
||||
if (file_size_bytes > 2147483648) {
|
||||
// > 2 GB
|
||||
warning_message = `This file size (${file_size_string}) is very large and will take at <strong>least</strong> a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Most audio, image, and video files can be compressed without a significant loss in quality. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly.`;
|
||||
} else if (file_size_bytes > 209715200) {
|
||||
// > 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take at <strong>least</strong> a few minutes to upload depending on your network connection. Be sure you have a stable network connection, especially if you are uploading over a wireless connection. Many business (convention centers, hotels, restaurants, etc) cap upload speeds significantly. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else if (file_size_bytes > 104857600) {
|
||||
// 100 MB to 200 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and will likely take a few minutes to upload depending on your network connection. Be sure you have a stable network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
} else {
|
||||
// File size in MB
|
||||
console.log(
|
||||
`File is too large to hash. File size: ${file_size_bytes / 1048576} MB`
|
||||
);
|
||||
// 50 to 100 MB
|
||||
warning_message = `This file size (${file_size_string}) is large and may take a few minutes to upload depending on your network connection. In some cases it may be worth compressing the file or embedded media. Many audio, image, and video files can be compressed without a significant loss in quality.`;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (file_hash) {
|
||||
console.log(
|
||||
`Found file hash to lookup: ${ae_util.shorten_string({ string: file_hash })}`
|
||||
);
|
||||
file_data['hash_sha256'] = file_hash;
|
||||
file_data['warning_untrusted_extension'] = warning_untrusted_extension;
|
||||
file_data['warning_legacy_extension'] = warning_legacy_extension;
|
||||
file_data['warning_size'] = warning_size;
|
||||
|
||||
let check_hosted_file_obj_w_hash_result = await check_hosted_file_obj_w_hash({
|
||||
file_data['warning_message'] = warning_message;
|
||||
|
||||
file_data['uploaded'] = null;
|
||||
file_data['uploaded_bytes'] = null;
|
||||
|
||||
// input_file_list_processed.push(JSON.parse(JSON.stringify(file_data)));
|
||||
// input_file_list_processed = input_file_list_processed;
|
||||
|
||||
// console.log(get_file_hash(file_item));
|
||||
// console.log(await get_file_hash(file_item));
|
||||
|
||||
let file_hash = null;
|
||||
|
||||
// Only hash files less than 2 GB (2147483648 bytes)!!!
|
||||
console.log(
|
||||
`File size: ${file_size_bytes / 1048576} MB (${file_size_bytes} bytes)`
|
||||
);
|
||||
if (file_size_bytes < 2000000000) {
|
||||
// > 2 GB 2 147 483 648
|
||||
file_hash = await ae_util.get_file_hash(file_item);
|
||||
} else {
|
||||
// File size in MB
|
||||
console.log(
|
||||
`File is too large to hash. File size: ${file_size_bytes / 1048576} MB`
|
||||
);
|
||||
}
|
||||
|
||||
if (file_hash) {
|
||||
console.log(
|
||||
`Found file hash to lookup: ${ae_util.shorten_string({ string: file_hash })}`
|
||||
);
|
||||
file_data['hash_sha256'] = file_hash;
|
||||
|
||||
let check_hosted_file_obj_w_hash_result =
|
||||
await check_hosted_file_obj_w_hash({
|
||||
api_cfg: $ae_api,
|
||||
hosted_file_hash: file_hash
|
||||
});
|
||||
|
||||
// console.log(check_hosted_file_obj_w_hash_result);
|
||||
// console.log(check_hosted_file_obj_w_hash_result);
|
||||
|
||||
if (
|
||||
check_hosted_file_obj_w_hash_result &&
|
||||
check_hosted_file_obj_w_hash_result.hosted_file_found_check
|
||||
) {
|
||||
console.log('Matching hash!!!');
|
||||
file_data['hash_sha256_match'] = true;
|
||||
// $ae_events.pres_mgmt.new_upload_list[i].hash_sha256_match = true;
|
||||
// $ae_events = $ae_events;
|
||||
}
|
||||
} else {
|
||||
file_data['hash_sha256'] = null;
|
||||
file_data['hash_sha256_match'] = false;
|
||||
if (
|
||||
check_hosted_file_obj_w_hash_result &&
|
||||
check_hosted_file_obj_w_hash_result.hosted_file_found_check
|
||||
) {
|
||||
console.log('Matching hash!!!');
|
||||
file_data['hash_sha256_match'] = true;
|
||||
// $ae_events.pres_mgmt.new_upload_list[i].hash_sha256_match = true;
|
||||
// $ae_events = $ae_events;
|
||||
}
|
||||
} else {
|
||||
file_data['hash_sha256'] = null;
|
||||
file_data['hash_sha256_match'] = false;
|
||||
}
|
||||
|
||||
processed_file_list.push(file_data);
|
||||
// input_file_list_processed.push(file_data);
|
||||
}
|
||||
|
||||
file_list_status = 'ready';
|
||||
console.log(processed_file_list);
|
||||
|
||||
// return JSON.parse(JSON.stringify(processed_file_list));
|
||||
return processed_file_list;
|
||||
}
|
||||
|
||||
function remove_file_from_filelist(index: number) {
|
||||
console.log('*** remove_file_from_filelist() ***');
|
||||
|
||||
// Can not use something like this because it is readonly:
|
||||
const dt = new DataTransfer();
|
||||
// let input = document.getElementById(input_element_id);
|
||||
|
||||
let input_element = document.querySelector(
|
||||
'input[type="file"].svelte_input_file_element'
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (!input_element) {
|
||||
console.error('Could not find the input element.');
|
||||
return false;
|
||||
}
|
||||
|
||||
let files = input_file_list;
|
||||
|
||||
if (!files || !files.length) {
|
||||
console.error('No files found in the file list.');
|
||||
file_list_status = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (index !== i)
|
||||
// Only include the file if it does not match the index value.
|
||||
dt.items.add(file);
|
||||
}
|
||||
|
||||
// NOTE: I thought just setting the input_element.files OR input_file_list would trigger the input_file_list change.
|
||||
// input_element.files = Object.assign({}, dt.files);
|
||||
input_element.files = dt.files; // Assign the updates list
|
||||
// input_file_list = null;
|
||||
input_file_list = dt.files; // I feel like this should not need to be done, but doing it anyways.
|
||||
|
||||
return true;
|
||||
}
|
||||
run(() => {
|
||||
if (input_file_list) {
|
||||
console.log(input_file_list);
|
||||
|
||||
process_file_list(input_file_list).then(function (result) {
|
||||
// console.log(result);
|
||||
|
||||
if (!result || !result.length) {
|
||||
processed_file_list = [];
|
||||
file_list_status = 'none';
|
||||
}
|
||||
|
||||
processed_file_list.push(file_data);
|
||||
// input_file_list_processed.push(file_data);
|
||||
}
|
||||
// Save the results to the file upload list to be displayed as a table.
|
||||
// input_file_list_processed = result; // Includes file hash
|
||||
|
||||
file_list_status = 'ready';
|
||||
console.log(processed_file_list);
|
||||
|
||||
// return JSON.parse(JSON.stringify(processed_file_list));
|
||||
return processed_file_list;
|
||||
// dispatch(
|
||||
// 'input_file_list_updated',
|
||||
// {
|
||||
// element_id: element_id,
|
||||
// input_file_list: input_file_list,
|
||||
// input_file_list_processed: result, // Includes file hash
|
||||
// }
|
||||
// );
|
||||
});
|
||||
} else {
|
||||
processed_file_list = [];
|
||||
file_list_status = 'none';
|
||||
}
|
||||
|
||||
function remove_file_from_filelist(index: number) {
|
||||
console.log('*** remove_file_from_filelist() ***');
|
||||
|
||||
// Can not use something like this because it is readonly:
|
||||
const dt = new DataTransfer();
|
||||
// let input = document.getElementById(input_element_id);
|
||||
|
||||
let input_element = document.querySelector('input[type="file"].svelte_input_file_element') as HTMLInputElement;
|
||||
|
||||
if (!input_element) {
|
||||
console.error('Could not find the input element.');
|
||||
return false;
|
||||
}
|
||||
|
||||
let files = input_file_list;
|
||||
|
||||
if (!files || !files.length) {
|
||||
console.error('No files found in the file list.');
|
||||
file_list_status = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (index !== i)
|
||||
// Only include the file if it does not match the index value.
|
||||
dt.items.add(file);
|
||||
}
|
||||
|
||||
// NOTE: I thought just setting the input_element.files OR input_file_list would trigger the input_file_list change.
|
||||
// input_element.files = Object.assign({}, dt.files);
|
||||
input_element.files = dt.files; // Assign the updates list
|
||||
// input_file_list = null;
|
||||
input_file_list = dt.files; // I feel like this should not need to be done, but doing it anyways.
|
||||
|
||||
return true;
|
||||
}
|
||||
run(() => {
|
||||
if (input_file_list) {
|
||||
console.log(input_file_list);
|
||||
|
||||
process_file_list(input_file_list).then(function (result) {
|
||||
// console.log(result);
|
||||
|
||||
if (!result || !result.length) {
|
||||
processed_file_list = [];
|
||||
file_list_status = 'none';
|
||||
}
|
||||
|
||||
// Save the results to the file upload list to be displayed as a table.
|
||||
// input_file_list_processed = result; // Includes file hash
|
||||
|
||||
// dispatch(
|
||||
// 'input_file_list_updated',
|
||||
// {
|
||||
// element_id: element_id,
|
||||
// input_file_list: input_file_list,
|
||||
// input_file_list_processed: result, // Includes file hash
|
||||
// }
|
||||
// );
|
||||
});
|
||||
} else {
|
||||
processed_file_list = [];
|
||||
file_list_status = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="svelte_element ae_element ae_input_file flex flex-col gap-1 items-center justify-center {container_class_li.join(
|
||||
class="svelte_element ae_element ae_input_file flex flex-col items-center justify-center gap-1 {container_class_li.join(
|
||||
' '
|
||||
)} text-center"
|
||||
>
|
||||
)} text-center">
|
||||
{#if file_list_status == 'processing'}
|
||||
<div
|
||||
class="file_list_status ae_warning preset-tonal-warning border border-warning-500 p-1 m-1"
|
||||
>
|
||||
<LoaderCircle size="1em" class="m-1 animate-spin" /> Processing selected file list...
|
||||
class="file_list_status ae_warning preset-tonal-warning border-warning-500 m-1 border p-1">
|
||||
<LoaderCircle size="1em" class="m-1 animate-spin" /> Processing selected
|
||||
file list...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -364,38 +386,38 @@
|
||||
remove_file_from_filelist(file_index);
|
||||
})}
|
||||
class="btn btn-md preset-tonal-warning hover:preset-filled-secondary-500 m-1"
|
||||
title="Remove file from upload list"
|
||||
>
|
||||
title="Remove file from upload list">
|
||||
<Minus size="1em" />
|
||||
<span class="hidden">Remove</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="file_filename text-wrap break-all md:break-words">
|
||||
<td
|
||||
class="file_filename text-wrap break-all md:break-words">
|
||||
{file_list_item.filename}
|
||||
</td>
|
||||
<td class="file_last_modified">{file_list_item.modified_datetime_string}</td
|
||||
>
|
||||
<td class="file_size" class:bg-pink-200={file_list_item.warning_size}>
|
||||
<td class="file_last_modified"
|
||||
>{file_list_item.modified_datetime_string}</td>
|
||||
<td
|
||||
class="file_size"
|
||||
class:bg-pink-200={file_list_item.warning_size}>
|
||||
{file_list_item.file_size_string}
|
||||
{#if $ae_sess.api_upload_kv[file_list_item.hash_sha256]}
|
||||
<span class="text-xs"
|
||||
>({$ae_sess.api_upload_kv[file_list_item.hash_sha256]
|
||||
.percent_completed}%)</span
|
||||
>
|
||||
>({$ae_sess.api_upload_kv[
|
||||
file_list_item.hash_sha256
|
||||
].percent_completed}%)</span>
|
||||
{/if}
|
||||
</td>
|
||||
<!-- <td class="file_type" class:warning_file_untrusted_extension={file_list_item.warning_untrusted_extension} class:warning_file_legacy_extension={file_list_item.warning_legacy_extension}>{file_list_item.type}</td> -->
|
||||
<td
|
||||
class="file_extension"
|
||||
class:bg-red-200={file_list_item.warning_untrusted_extension}
|
||||
class:bg-pink-200={file_list_item.warning_legacy_extension}
|
||||
>
|
||||
class:bg-pink-200={file_list_item.warning_legacy_extension}>
|
||||
{file_list_item.guessed_extension}
|
||||
</td>
|
||||
<td
|
||||
class="file_hash file_hash256"
|
||||
class:bg-pink-200={file_list_item.hash_sha256_match}
|
||||
>
|
||||
class:bg-pink-200={file_list_item.hash_sha256_match}>
|
||||
{ae_util.shorten_string({
|
||||
string: file_list_item.hash_sha256,
|
||||
begin_length: 5,
|
||||
@@ -411,18 +433,18 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
th {
|
||||
text-align: center;
|
||||
/* font-size: smaller; */
|
||||
}
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
.file_last_modified {
|
||||
font-size: smaller;
|
||||
}
|
||||
th {
|
||||
text-align: center;
|
||||
/* font-size: smaller; */
|
||||
}
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
.file_last_modified {
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.file_hash {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
.file_hash {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,127 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
// import { liveQuery } from "dexie";
|
||||
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
// import { liveQuery } from "dexie";
|
||||
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
|
||||
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { api } from '$lib/api/api';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { api } from '$lib/api/api';
|
||||
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
import {
|
||||
ae_loc,
|
||||
ae_sess,
|
||||
ae_api,
|
||||
ae_trig,
|
||||
slct,
|
||||
slct_trigger
|
||||
} from '$lib/stores/ae_stores';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct,
|
||||
events_trigger
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import AE_Comp_Hosted_Files_Download_Button from '$lib/ae_core/ae_comp__hosted_files_download_button.svelte';
|
||||
import { Check, Clock, Download, Eye, EyeOff, FileImage, FolderOpen, Laptop, LoaderCircle, Monitor, Pencil, RefreshCw, Save, Trash2, TriangleAlert } from '@lucide/svelte';
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
container_class_li?: string | Array<string>;
|
||||
lq__event_file_obj_li: any;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean;
|
||||
allow_moderator?: boolean;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
// WHY: event_file.event_session_type_code is NULL for presenter-linked files because
|
||||
// v_event_file joins event_session only via event_file.event_session_id, which is NULL
|
||||
// when for_type='event_presenter'. The wrapper derives the correct type from the
|
||||
// presenter → presentation → session chain and passes it here as context.
|
||||
context_session_type_code?: string | null;
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
import {
|
||||
ae_loc,
|
||||
ae_sess,
|
||||
ae_api,
|
||||
ae_trig,
|
||||
slct,
|
||||
slct_trigger
|
||||
} from '$lib/stores/ae_stores';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct,
|
||||
events_trigger
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import AE_Comp_Hosted_Files_Download_Button from '$lib/ae_core/ae_comp__hosted_files_download_button.svelte';
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileImage,
|
||||
FolderOpen,
|
||||
Laptop,
|
||||
LoaderCircle,
|
||||
Monitor,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
TriangleAlert
|
||||
} from '@lucide/svelte';
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
container_class_li?: string | Array<string>;
|
||||
lq__event_file_obj_li: any;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean;
|
||||
allow_moderator?: boolean;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
// WHY: event_file.event_session_type_code is NULL for presenter-linked files because
|
||||
// v_event_file joins event_session only via event_file.event_session_id, which is NULL
|
||||
// when for_type='event_presenter'. The wrapper derives the correct type from the
|
||||
// presenter → presentation → session chain and passes it here as context.
|
||||
context_session_type_code?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
log_lvl = 0,
|
||||
container_class_li = [],
|
||||
lq__event_file_obj_li,
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default',
|
||||
context_session_type_code = null
|
||||
}: Props = $props();
|
||||
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
let ae_promises: key_val = $state({});
|
||||
let ae_tmp: key_val = $state({});
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = $events_loc.pres_mgmt.show__direct_download;
|
||||
// let ae_triggers: key_val = {};
|
||||
|
||||
onMount(() => {
|
||||
if (log_lvl) {
|
||||
console.log(
|
||||
`Element - Manage Event File List: link_to_type: ${link_to_type}; link_to_id: ${link_to_id}`
|
||||
);
|
||||
console.log(
|
||||
`allow_basic: ${allow_basic}; allow_moderator: ${allow_moderator}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let {
|
||||
log_lvl = 0,
|
||||
container_class_li = [],
|
||||
lq__event_file_obj_li,
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default',
|
||||
context_session_type_code = null
|
||||
}: Props = $props();
|
||||
let clipboard_success = $state(false);
|
||||
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
// PDF → Image conversion state (keyed by event_file_id)
|
||||
// WHY: Poster sessions display files in the Launcher modal via <img> tag — PDFs can't render there.
|
||||
// Presenters upload PDFs which must be converted server-side to high-res webp images.
|
||||
// Button is only shown in edit_mode for PDF files linked to a poster-type session.
|
||||
// Backend: /v3/action/hosted_file/{id}/convert_file runs pdf2image (3840px wide, first page only)
|
||||
// and saves the result as a new hosted_file record linked to the same parent object.
|
||||
type ConvertStatus = 'idle' | 'converting' | 'done' | 'error';
|
||||
let convert_status_kv: Record<string, ConvertStatus> = $state({});
|
||||
let convert_result_kv: key_val = $state({});
|
||||
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
let ae_promises: key_val = $state({});
|
||||
let ae_tmp: key_val = $state({});
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = $events_loc.pres_mgmt.show__direct_download;
|
||||
// let ae_triggers: key_val = {};
|
||||
|
||||
onMount(() => {
|
||||
if (log_lvl) {
|
||||
console.log(
|
||||
`Element - Manage Event File List: link_to_type: ${link_to_type}; link_to_id: ${link_to_id}`
|
||||
);
|
||||
console.log(
|
||||
`allow_basic: ${allow_basic}; allow_moderator: ${allow_moderator}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let clipboard_success = $state(false);
|
||||
|
||||
// PDF → Image conversion state (keyed by event_file_id)
|
||||
// WHY: Poster sessions display files in the Launcher modal via <img> tag — PDFs can't render there.
|
||||
// Presenters upload PDFs which must be converted server-side to high-res webp images.
|
||||
// Button is only shown in edit_mode for PDF files linked to a poster-type session.
|
||||
// Backend: /v3/action/hosted_file/{id}/convert_file runs pdf2image (3840px wide, first page only)
|
||||
// and saves the result as a new hosted_file record linked to the same parent object.
|
||||
type ConvertStatus = 'idle' | 'converting' | 'done' | 'error';
|
||||
let convert_status_kv: Record<string, ConvertStatus> = $state({});
|
||||
let convert_result_kv: key_val = $state({});
|
||||
|
||||
async function handle_convert_pdf_to_image(event_file_obj: key_val) {
|
||||
const file_id = event_file_obj.event_file_id;
|
||||
convert_status_kv[file_id] = 'converting';
|
||||
try {
|
||||
// Link the new image to the most specific parent available
|
||||
const link_to_type_val = event_file_obj.event_session_id ? 'event_session'
|
||||
: event_file_obj.event_presentation_id ? 'event_presentation'
|
||||
: 'event';
|
||||
const link_to_id_val = event_file_obj.event_session_id
|
||||
|| event_file_obj.event_presentation_id
|
||||
|| event_file_obj.event_id;
|
||||
const filename_no_ext = (event_file_obj.filename ?? 'poster_image').replace(/\.pdf$/i, '');
|
||||
const url = `${$ae_api.base_url}/v3/hosted_file/${event_file_obj.hosted_file_id}/convert_file`
|
||||
+ `?link_to_type=${encodeURIComponent(link_to_type_val)}`
|
||||
+ `&link_to_id=${encodeURIComponent(link_to_id_val)}`
|
||||
+ `&filename_no_ext=${encodeURIComponent(filename_no_ext)}`
|
||||
+ `&to_type=webp`;
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'x-aether-api-key': $ae_api.api_secret_key,
|
||||
'x-account-id': String($ae_api.account_id ?? '')
|
||||
}
|
||||
});
|
||||
const body = await resp.json();
|
||||
if (resp.ok && body?.data) {
|
||||
convert_result_kv[file_id] = body.data;
|
||||
convert_status_kv[file_id] = 'done';
|
||||
} else {
|
||||
console.error('[convert_pdf] API error:', body);
|
||||
convert_status_kv[file_id] = 'error';
|
||||
async function handle_convert_pdf_to_image(event_file_obj: key_val) {
|
||||
const file_id = event_file_obj.event_file_id;
|
||||
convert_status_kv[file_id] = 'converting';
|
||||
try {
|
||||
// Link the new image to the most specific parent available
|
||||
const link_to_type_val = event_file_obj.event_session_id
|
||||
? 'event_session'
|
||||
: event_file_obj.event_presentation_id
|
||||
? 'event_presentation'
|
||||
: 'event';
|
||||
const link_to_id_val =
|
||||
event_file_obj.event_session_id ||
|
||||
event_file_obj.event_presentation_id ||
|
||||
event_file_obj.event_id;
|
||||
const filename_no_ext = (
|
||||
event_file_obj.filename ?? 'poster_image'
|
||||
).replace(/\.pdf$/i, '');
|
||||
const url =
|
||||
`${$ae_api.base_url}/v3/hosted_file/${event_file_obj.hosted_file_id}/convert_file` +
|
||||
`?link_to_type=${encodeURIComponent(link_to_type_val)}` +
|
||||
`&link_to_id=${encodeURIComponent(link_to_id_val)}` +
|
||||
`&filename_no_ext=${encodeURIComponent(filename_no_ext)}` +
|
||||
`&to_type=webp`;
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'x-aether-api-key': $ae_api.api_secret_key,
|
||||
'x-account-id': String($ae_api.account_id ?? '')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[convert_pdf] Fetch failed:', err);
|
||||
});
|
||||
const body = await resp.json();
|
||||
if (resp.ok && body?.data) {
|
||||
convert_result_kv[file_id] = body.data;
|
||||
convert_status_kv[file_id] = 'done';
|
||||
} else {
|
||||
console.error('[convert_pdf] API error:', body);
|
||||
convert_status_kv[file_id] = 'error';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[convert_pdf] Fetch failed:', err);
|
||||
convert_status_kv[file_id] = 'error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="float-right flex flex-row items-center">
|
||||
@@ -152,10 +174,9 @@
|
||||
// $slct_trigger = 'load__event_file_obj_li';
|
||||
// ae_tmp.show__file_li = true;
|
||||
}}
|
||||
class="btn btn-sm p-1 m-1 preset-tonal-tertiary hover:preset-tonal-warning border border-warning-500 transition hover:transition-all *:hover:inline"
|
||||
class="btn btn-sm preset-tonal-tertiary hover:preset-tonal-warning border-warning-500 m-1 border p-1 transition hover:transition-all *:hover:inline"
|
||||
class:hidden={!$ae_loc.edit_mode || !$ae_loc.authenticated_access}
|
||||
title="Refresh the list of files"
|
||||
>
|
||||
title="Refresh the list of files">
|
||||
<RefreshCw size="1em" class="m-1" />
|
||||
<div class="hidden">Files</div>
|
||||
</button>
|
||||
@@ -166,10 +187,9 @@
|
||||
console.log('*** Show Alt Download button clicked ***');
|
||||
ae_tmp.show__direct_download = !ae_tmp.show__direct_download;
|
||||
}}
|
||||
class="btn btn-sm p-1 m-1 preset-tonal-tertiary hover:preset-tonal-warning border border-warning-500 transition hover:transition-all *:hover:inline"
|
||||
class="btn btn-sm preset-tonal-tertiary hover:preset-tonal-warning border-warning-500 m-1 border p-1 transition hover:transition-all *:hover:inline"
|
||||
class:hidden={!$ae_loc.edit_mode || !$ae_loc.trusted_access}
|
||||
title="Toggle direct download link and copy link button"
|
||||
>
|
||||
title="Toggle direct download link and copy link button">
|
||||
<Download size="1em" class="m-1" />
|
||||
<div class="hidden">
|
||||
{ae_tmp.show__direct_download ? 'Alt On' : 'Alt Download Off'}
|
||||
@@ -179,20 +199,17 @@
|
||||
|
||||
<section
|
||||
class="svelte_component event_file_uploaded_manage {container_class_li}"
|
||||
class:text-sm={display_mode != 'default'}
|
||||
>
|
||||
class:text-sm={display_mode != 'default'}>
|
||||
<h3
|
||||
class="h6"
|
||||
class:hidden={!$lq__event_file_obj_li?.length ||
|
||||
display_mode != 'default'}
|
||||
>
|
||||
display_mode != 'default'}>
|
||||
Manage Files:
|
||||
<span
|
||||
class="font-bold bg-success-100 px-4 border rounded-lg border-success-200"
|
||||
class="bg-success-100 border-success-200 rounded-lg border px-4 font-bold"
|
||||
title="Files for {link_to_type ?? '-- not set --'}: {link_to_id ??
|
||||
'-- not set --'} (files: {$lq__event_file_obj_li?.length ??
|
||||
'None'})"
|
||||
>
|
||||
'None'})">
|
||||
<FolderOpen size="1em" class="mx-1" />
|
||||
{@html $lq__event_file_obj_li
|
||||
? `${$lq__event_file_obj_li.length}×`
|
||||
@@ -201,8 +218,8 @@
|
||||
</h3>
|
||||
|
||||
{#if $lq__event_file_obj_li && $lq__event_file_obj_li.length}
|
||||
<div class="overflow-auto w-full">
|
||||
<table class="table-auto w-full">
|
||||
<div class="w-full overflow-auto">
|
||||
<table class="w-full table-auto">
|
||||
{#if display_mode === 'default'}
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -211,15 +228,13 @@
|
||||
<th
|
||||
class="text-center"
|
||||
class:hidden={!allow_basic &&
|
||||
!$ae_loc.trusted_access}>Options</th
|
||||
>
|
||||
!$ae_loc.trusted_access}>Options</th>
|
||||
{/if}
|
||||
{#if display_mode === 'default'}
|
||||
<th
|
||||
class="text-center"
|
||||
class:hidden={!allow_basic &&
|
||||
!$ae_loc.trusted_access}>Status</th
|
||||
>
|
||||
!$ae_loc.trusted_access}>Status</th>
|
||||
{/if}
|
||||
<th class="text-center">Meta</th>
|
||||
</tr>
|
||||
@@ -228,11 +243,12 @@
|
||||
|
||||
<tbody>
|
||||
{#each $lq__event_file_obj_li as event_file_obj (event_file_obj.event_file_id)}
|
||||
{@const ExtIcon = ae_util.file_extension_icon_lucide(event_file_obj.extension)}
|
||||
{@const ExtIcon = ae_util.file_extension_icon_lucide(
|
||||
event_file_obj.extension
|
||||
)}
|
||||
<tr
|
||||
class="ae_obj obj_event_file border-t border-b border-surface-200-800 hover:bg-surface-100-900 hover:border-surface-300-700 transition-colors duration-200"
|
||||
class:dim={event_file_obj?.hide}
|
||||
>
|
||||
class="ae_obj obj_event_file border-surface-200-800 hover:bg-surface-100-900 hover:border-surface-300-700 border-t border-b transition-colors duration-200"
|
||||
class:dim={event_file_obj?.hide}>
|
||||
<td class="event_file__file align-middle">
|
||||
{#if $events_sess.pres_mgmt?.show_field_edit__filename != event_file_obj.event_file_id}
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -242,8 +258,7 @@
|
||||
show_divider={true}
|
||||
show_direct_download={ae_tmp.show__direct_download}
|
||||
max_filename={30}
|
||||
classes="btn btn-sm lg:btn-md preset-tonal-primary hover:preset-filled-primary-500 min-w-72 lg:min-w-96"
|
||||
/>
|
||||
classes="btn btn-sm lg:btn-md preset-tonal-primary hover:preset-filled-primary-500 min-w-72 lg:min-w-96" />
|
||||
|
||||
<!-- PDF → webp convert button: only for poster sessions in edit mode -->
|
||||
{#if $ae_loc.edit_mode && event_file_obj?.extension === 'pdf' && (event_file_obj?.event_session_type_code === 'poster' || context_session_type_code === 'poster')}
|
||||
@@ -251,33 +266,54 @@
|
||||
{#if !convert_status_kv[event_file_obj.event_file_id] || convert_status_kv[event_file_obj.event_file_id] === 'idle'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-warning border border-warning-500"
|
||||
class="btn btn-sm preset-tonal-warning border-warning-500 border"
|
||||
title="Convert this PDF to a high-res webp image for use in the Launcher poster display."
|
||||
onclick={() => handle_convert_pdf_to_image(event_file_obj)}
|
||||
>
|
||||
<FileImage size="1em" class="mx-1" />
|
||||
onclick={() =>
|
||||
handle_convert_pdf_to_image(
|
||||
event_file_obj
|
||||
)}>
|
||||
<FileImage
|
||||
size="1em"
|
||||
class="mx-1" />
|
||||
Convert PDF → Image
|
||||
</button>
|
||||
{:else if convert_status_kv[event_file_obj.event_file_id] === 'converting'}
|
||||
<span class="btn btn-sm preset-tonal-surface opacity-60 cursor-wait">
|
||||
<LoaderCircle size="1em" class="mx-1 animate-spin" />
|
||||
<span
|
||||
class="btn btn-sm preset-tonal-surface cursor-wait opacity-60">
|
||||
<LoaderCircle
|
||||
size="1em"
|
||||
class="mx-1 animate-spin" />
|
||||
Converting…
|
||||
</span>
|
||||
{:else if convert_status_kv[event_file_obj.event_file_id] === 'done'}
|
||||
<span class="btn btn-sm preset-tonal-success" title="Conversion complete. New webp hosted_file created: {convert_result_kv[event_file_obj.event_file_id]?.filename ?? ''}">
|
||||
<Check size="1em" class="mx-1" />
|
||||
Done — {convert_result_kv[event_file_obj.event_file_id]?.filename ?? 'image created'}
|
||||
<span
|
||||
class="btn btn-sm preset-tonal-success"
|
||||
title="Conversion complete. New webp hosted_file created: {convert_result_kv[
|
||||
event_file_obj
|
||||
.event_file_id
|
||||
]?.filename ?? ''}">
|
||||
<Check
|
||||
size="1em"
|
||||
class="mx-1" />
|
||||
Done — {convert_result_kv[
|
||||
event_file_obj
|
||||
.event_file_id
|
||||
]?.filename ??
|
||||
'image created'}
|
||||
</span>
|
||||
{:else if convert_status_kv[event_file_obj.event_file_id] === 'error'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-error border border-error-500"
|
||||
class="btn btn-sm preset-tonal-error border-error-500 border"
|
||||
title="Conversion failed. Click to retry."
|
||||
onclick={() => {
|
||||
convert_status_kv[event_file_obj.event_file_id] = 'idle';
|
||||
}}
|
||||
>
|
||||
<TriangleAlert size="1em" class="mx-1" />
|
||||
convert_status_kv[
|
||||
event_file_obj.event_file_id
|
||||
] = 'idle';
|
||||
}}>
|
||||
<TriangleAlert
|
||||
size="1em"
|
||||
class="mx-1" />
|
||||
Failed — Retry?
|
||||
</button>
|
||||
{/if}
|
||||
@@ -286,14 +322,11 @@
|
||||
|
||||
{#if ae_tmp.show__direct_download}
|
||||
<div
|
||||
class="px-4 py-2 flex flex-col gap-0.5 bg-surface-100/50 rounded-lg border border-surface-500/10"
|
||||
>
|
||||
class="bg-surface-100/50 border-surface-500/10 flex flex-col gap-0.5 rounded-lg border px-4 py-2">
|
||||
<div
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
class="flex flex-row items-center gap-2">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase opacity-50 w-24"
|
||||
>
|
||||
class="w-24 text-[10px] font-bold uppercase opacity-50">
|
||||
Access Link:
|
||||
</span>
|
||||
<MyClipboard
|
||||
@@ -329,9 +362,8 @@
|
||||
.tmp_val__filename_no_ext
|
||||
}
|
||||
data-original_value={event_file_obj.filename}
|
||||
class="input min-w-72 lg:min-w-96 text-sm bg-warning-100"
|
||||
title="Rename this file. No extension."
|
||||
/>
|
||||
class="input bg-warning-100 min-w-72 text-sm lg:min-w-96"
|
||||
title="Rename this file. No extension." />
|
||||
{#if $events_sess.pres_mgmt.tmp_val__filename_no_ext.trim() != event_file_obj.filename_no_ext}
|
||||
<button
|
||||
type="button"
|
||||
@@ -389,15 +421,17 @@
|
||||
);
|
||||
}}
|
||||
class="btn btn-sm preset-tonal-tertiary hover:preset-tonal-success"
|
||||
title="Save changes"
|
||||
>
|
||||
title="Save changes">
|
||||
{#await ae_promises.update__event_file_obj}
|
||||
<LoaderCircle size="1em" class="mx-1 animate-spin" />
|
||||
<LoaderCircle
|
||||
size="1em"
|
||||
class="mx-1 animate-spin" />
|
||||
<span class=""
|
||||
>Saving {event_file_obj.extension}</span
|
||||
>
|
||||
>Saving {event_file_obj.extension}</span>
|
||||
{:then}
|
||||
<Save size="1em" class="mx-1" />
|
||||
<Save
|
||||
size="1em"
|
||||
class="mx-1" />
|
||||
Save {event_file_obj.extension}
|
||||
filename?
|
||||
{/await}
|
||||
@@ -411,8 +445,7 @@
|
||||
<td
|
||||
class="event_file__options"
|
||||
class:hidden={!allow_basic &&
|
||||
!$ae_loc.trusted_access}
|
||||
>
|
||||
!$ae_loc.trusted_access}>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
@@ -439,8 +472,7 @@
|
||||
.pres_mgmt
|
||||
.show_field_edit__filename ==
|
||||
event_file_obj.event_file_id}
|
||||
title={`Rename this file? "${event_file_obj.filename}"}`}
|
||||
>
|
||||
title={`Rename this file? "${event_file_obj.filename}"}`}>
|
||||
<Pencil size="1em" class="mx-1" />
|
||||
{#if $events_sess.pres_mgmt?.show_field_edit__filename == event_file_obj.event_file_id}
|
||||
Cancel?
|
||||
@@ -504,22 +536,26 @@
|
||||
);
|
||||
}}
|
||||
class="btn btn-sm preset-tonal-tertiary hover:preset-tonal-success"
|
||||
title="Hide this file from the presentation launcher"
|
||||
>
|
||||
title="Hide this file from the presentation launcher">
|
||||
<!-- Users see this as the "Archive" option button -->
|
||||
<!-- {@html (event_file_obj?.hide ? '<span class="fas fa-archive m-1"></span> Unarchive' : '<span class="fas fa-archive m-1"></span> Archive')}
|
||||
-->
|
||||
|
||||
{#await ae_promises.update__event_file_obj}
|
||||
<LoaderCircle size="1em" class="mx-1 animate-spin" />
|
||||
<LoaderCircle
|
||||
size="1em"
|
||||
class="mx-1 animate-spin" />
|
||||
<span class=""
|
||||
>Saving {event_file_obj.extension}</span
|
||||
>
|
||||
>Saving {event_file_obj.extension}</span>
|
||||
{:then}
|
||||
{#if event_file_obj.hide}
|
||||
<Eye size="1em" class="m-1" /> Unhide File
|
||||
<Eye
|
||||
size="1em"
|
||||
class="m-1" /> Unhide File
|
||||
{:else}
|
||||
<EyeOff size="1em" class="m-1" /> Hide
|
||||
<EyeOff
|
||||
size="1em"
|
||||
class="m-1" /> Hide
|
||||
{/if}
|
||||
{/await}
|
||||
</button>
|
||||
@@ -550,8 +586,7 @@
|
||||
);
|
||||
}}
|
||||
class="btn btn-sm preset-tonal-tertiary hover:preset-tonal-success"
|
||||
title="Delete this file"
|
||||
>
|
||||
title="Delete this file">
|
||||
<Trash2 size="1em" class="mx-1" />
|
||||
<!-- <span class="fas fa-minus mx-1"></span> -->
|
||||
Delete
|
||||
@@ -564,16 +599,18 @@
|
||||
<td
|
||||
class="event_file__status"
|
||||
class:hidden={!allow_basic &&
|
||||
!$ae_loc.trusted_access}
|
||||
>
|
||||
!$ae_loc.trusted_access}>
|
||||
<div
|
||||
class="flex flex-col gap-1 items-center justify-center text-sm"
|
||||
>
|
||||
class="flex flex-col items-center justify-center gap-1 text-sm">
|
||||
<div class="">
|
||||
{#if event_file_obj.open_in_os == 'win'}
|
||||
MS Windows <Monitor size="1em" class="inline-block" />
|
||||
MS Windows <Monitor
|
||||
size="1em"
|
||||
class="inline-block" />
|
||||
{:else if event_file_obj.open_in_os == 'mac'}
|
||||
Apple macOS <Laptop size="1em" class="inline-block" />
|
||||
Apple macOS <Laptop
|
||||
size="1em"
|
||||
class="inline-block" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -581,8 +618,7 @@
|
||||
<div>
|
||||
<label
|
||||
for="file_purpose"
|
||||
class="text-sm mx-1 hidden"
|
||||
>
|
||||
class="mx-1 hidden text-sm">
|
||||
Purpose:
|
||||
</label>
|
||||
<select
|
||||
@@ -633,25 +669,28 @@
|
||||
|
||||
// ae_triggers.update_event_file_purpose = true;
|
||||
}}
|
||||
class="select min-w-fit max-w-fit text-xs mx-1 border border-surface-300-700 rounded-md p-1 bg-surface-50-900 hover:border-surface-400-600 transition-colors duration-200"
|
||||
>
|
||||
class="select border-surface-300-700 bg-surface-50-900 hover:border-surface-400-600 mx-1 max-w-fit min-w-fit rounded-md border p-1 text-xs transition-colors duration-200">
|
||||
<option
|
||||
value={null}
|
||||
selected={!event_file_obj.file_purpose}
|
||||
>-- purpose not set --</option
|
||||
>
|
||||
>-- purpose not set --</option>
|
||||
{#if $events_loc.pres_mgmt?.file_purpose_option_kv}
|
||||
{#each Object.entries($events_loc.pres_mgmt.file_purpose_option_kv as any) as [key, file_purpose_option] (key)}
|
||||
<option
|
||||
value={key}
|
||||
selected={event_file_obj.file_purpose ===
|
||||
key}
|
||||
disabled={(file_purpose_option as any)?.disabled &&
|
||||
disabled={(
|
||||
file_purpose_option as any
|
||||
)?.disabled &&
|
||||
!$ae_loc.edit_mode}
|
||||
class:hidden={(file_purpose_option as any)?.hidden &&
|
||||
!$ae_loc.edit_mode}
|
||||
>
|
||||
{(file_purpose_option as any)?.name}
|
||||
class:hidden={(
|
||||
file_purpose_option as any
|
||||
)?.hidden &&
|
||||
!$ae_loc.edit_mode}>
|
||||
{(
|
||||
file_purpose_option as any
|
||||
)?.name}
|
||||
</option>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -675,16 +714,16 @@
|
||||
<!-- {event_file_obj.hosted_file_content_type} -->
|
||||
|
||||
<span
|
||||
class="w-full flex flex-col lg:flex-row justify-between"
|
||||
>
|
||||
class="flex w-full flex-col justify-between lg:flex-row">
|
||||
<span
|
||||
class:hidden={display_mode !=
|
||||
'default'}
|
||||
>
|
||||
'default'}>
|
||||
Type:
|
||||
<strong
|
||||
>{event_file_obj.extension}
|
||||
<ExtIcon size="1em" class="inline-block" />
|
||||
<ExtIcon
|
||||
size="1em"
|
||||
class="inline-block" />
|
||||
</strong>
|
||||
<!-- {#if event_file_obj.open_in_os == 'win'}
|
||||
<strong>
|
||||
@@ -699,28 +738,23 @@
|
||||
<span>
|
||||
<span
|
||||
class:hidden={display_mode !=
|
||||
'default'}
|
||||
>
|
||||
'default'}>
|
||||
Size:
|
||||
</span>
|
||||
<strong
|
||||
>{ae_util.format_bytes(
|
||||
event_file_obj.file_size
|
||||
)}</strong
|
||||
>
|
||||
)}</strong>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="w-full flex flex-col lg:flex-row justify-between"
|
||||
>
|
||||
class="flex w-full flex-col justify-between lg:flex-row">
|
||||
<span
|
||||
title="SHA 256: {event_file_obj.hash_sha256}"
|
||||
>
|
||||
title="SHA 256: {event_file_obj.hash_sha256}">
|
||||
<span
|
||||
class:hidden={display_mode !=
|
||||
'default'}
|
||||
>
|
||||
'default'}>
|
||||
Hash:
|
||||
</span>
|
||||
<strong
|
||||
@@ -729,22 +763,18 @@
|
||||
>{event_file_obj.hash_sha256.slice(
|
||||
0,
|
||||
10
|
||||
)}…</strong
|
||||
>
|
||||
)}…</strong>
|
||||
</span>
|
||||
<span
|
||||
class:hidden={!$ae_loc.administrator_access ||
|
||||
display_mode != 'default'}
|
||||
>
|
||||
display_mode != 'default'}>
|
||||
<span
|
||||
class:hidden={display_mode !=
|
||||
'default'}
|
||||
>
|
||||
'default'}>
|
||||
ID:
|
||||
</span>
|
||||
<strong
|
||||
>{event_file_obj.hosted_file_id}</strong
|
||||
>
|
||||
>{event_file_obj.hosted_file_id}</strong>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -770,8 +800,7 @@
|
||||
event_file_obj?.created_on,
|
||||
minutes: 2880
|
||||
}
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
{#if display_mode == 'default'}
|
||||
<!-- <span class="fas fa-cloud-upload-alt mx-1"></span> -->
|
||||
<!-- Uploaded: -->
|
||||
@@ -792,8 +821,7 @@
|
||||
>{ae_util.iso_datetime_formatter(
|
||||
event_file_obj.created_on,
|
||||
'time_12_short_no_leading'
|
||||
)}</strong
|
||||
>
|
||||
)}</strong>
|
||||
<!-- {event_file_obj.updated_on} -->
|
||||
{:else}
|
||||
<!-- <span class="fas fa-calendar-day mx-1"></span> -->
|
||||
@@ -807,8 +835,7 @@
|
||||
>{ae_util.iso_datetime_formatter(
|
||||
event_file_obj.created_on,
|
||||
'time_12_short_no_leading'
|
||||
)}</strong
|
||||
>
|
||||
)}</strong>
|
||||
</strong>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -822,8 +849,7 @@
|
||||
{:else}
|
||||
<p
|
||||
class="w-96 text-center text-gray-500"
|
||||
class:hidden={display_mode != 'default'}
|
||||
>
|
||||
class:hidden={display_mode != 'default'}>
|
||||
No files uploaded to display
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,94 +1,100 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
// import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
container_class_li?: string | Array<string>;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean;
|
||||
allow_moderator?: boolean;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
}
|
||||
interface Props {
|
||||
// import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
container_class_li?: string | Array<string>;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean;
|
||||
allow_moderator?: boolean;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
}
|
||||
|
||||
let {
|
||||
container_class_li = [],
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default'
|
||||
}: Props = $props();
|
||||
let {
|
||||
container_class_li = [],
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default'
|
||||
}: Props = $props();
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import Element_manage_event_file_li from '$lib/elements/element_manage_event_file_li.svelte';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import Element_manage_event_file_li from '$lib/elements/element_manage_event_file_li.svelte';
|
||||
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
// import { ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
// import { events_loc, events_sess, events_slct, events_trigger } from '$lib/stores/ae_events_stores';
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
// import { ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
// import { events_loc, events_sess, events_slct, events_trigger } from '$lib/stores/ae_events_stores';
|
||||
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
// let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = {};
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = false;
|
||||
// let ae_triggers: key_val = {};
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
// let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = {};
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = false;
|
||||
// let ae_triggers: key_val = {};
|
||||
|
||||
// WHY: v_event_file joins event_session only via event_file.event_session_id.
|
||||
// For files linked to a presentation or presenter, event_session_id is NULL on
|
||||
// the file record itself, so event_session_type_code is always NULL from the API.
|
||||
// Derive the session type from Dexie: presentation → event_session_id → type_code,
|
||||
// or presenter → event_presentation_id → event_session_id → type_code.
|
||||
let lq__context_session_type_code = $derived(
|
||||
liveQuery(async () => {
|
||||
if (link_to_type === 'event_presentation' && link_to_id) {
|
||||
const presentation = await db_events.presentation.get(link_to_id);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(presentation.event_session_id);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
if (link_to_type === 'event_presenter' && link_to_id) {
|
||||
const presenter = await db_events.presenter.get(link_to_id);
|
||||
if (!presenter?.event_presentation_id) return null;
|
||||
const presentation = await db_events.presentation.get(presenter.event_presentation_id);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(presentation.event_session_id);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
// WHY: v_event_file joins event_session only via event_file.event_session_id.
|
||||
// For files linked to a presentation or presenter, event_session_id is NULL on
|
||||
// the file record itself, so event_session_type_code is always NULL from the API.
|
||||
// Derive the session type from Dexie: presentation → event_session_id → type_code,
|
||||
// or presenter → event_presentation_id → event_session_id → type_code.
|
||||
let lq__context_session_type_code = $derived(
|
||||
liveQuery(async () => {
|
||||
if (link_to_type === 'event_presentation' && link_to_id) {
|
||||
const presentation = await db_events.presentation.get(link_to_id);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(
|
||||
presentation.event_session_id
|
||||
);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
if (link_to_type === 'event_presenter' && link_to_id) {
|
||||
const presenter = await db_events.presenter.get(link_to_id);
|
||||
if (!presenter?.event_presentation_id) return null;
|
||||
const presentation = await db_events.presentation.get(
|
||||
presenter.event_presentation_id
|
||||
);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(
|
||||
presentation.event_session_id
|
||||
);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
let dq__where_val = $derived(`${link_to_type}_id`);
|
||||
let dq__where_eq_val = $derived(link_to_id);
|
||||
let dq__where_val = $derived(`${link_to_type}_id`);
|
||||
let dq__where_eq_val = $derived(link_to_id);
|
||||
|
||||
// This should include all files that are associated with an object (event, location, session, presenter, etc.)
|
||||
// I am not sure why, but doing reverse() and then sortBy() seems to sort in descending order.
|
||||
let lq__event_file_obj_li = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!dq__where_eq_val) return [];
|
||||
let results = await db_events.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.or('for_id')
|
||||
.equals(dq__where_eq_val)
|
||||
.filter((file) => {
|
||||
// If using for_id, we should also verify for_type to avoid accidental cross-links
|
||||
if (file.for_id === dq__where_eq_val) {
|
||||
return file.for_type === link_to_type;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.reverse()
|
||||
.sortBy('created_on');
|
||||
// .toArray()
|
||||
return results;
|
||||
})
|
||||
);
|
||||
// This should include all files that are associated with an object (event, location, session, presenter, etc.)
|
||||
// I am not sure why, but doing reverse() and then sortBy() seems to sort in descending order.
|
||||
let lq__event_file_obj_li = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!dq__where_eq_val) return [];
|
||||
let results = await db_events.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.or('for_id')
|
||||
.equals(dq__where_eq_val)
|
||||
.filter((file) => {
|
||||
// If using for_id, we should also verify for_type to avoid accidental cross-links
|
||||
if (file.for_id === dq__where_eq_val) {
|
||||
return file.for_type === link_to_type;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.reverse()
|
||||
.sortBy('created_on');
|
||||
// .toArray()
|
||||
return results;
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<Element_manage_event_file_li
|
||||
@@ -99,5 +105,4 @@
|
||||
{allow_moderator}
|
||||
{container_class_li}
|
||||
{display_mode}
|
||||
context_session_type_code={$lq__context_session_type_code ?? null}
|
||||
/>
|
||||
context_session_type_code={$lq__context_session_type_code ?? null} />
|
||||
|
||||
@@ -1,95 +1,101 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
// import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
container_class_li?: string | Array<string>;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean;
|
||||
allow_moderator?: boolean;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
}
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
// import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
container_class_li?: string | Array<string>;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean;
|
||||
allow_moderator?: boolean;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
}
|
||||
|
||||
let {
|
||||
log_lvl = 0,
|
||||
container_class_li = [],
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default'
|
||||
}: Props = $props();
|
||||
let {
|
||||
log_lvl = 0,
|
||||
container_class_li = [],
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default'
|
||||
}: Props = $props();
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import Element_manage_event_file_li from '$lib/elements/element_manage_event_file_li.svelte';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import Element_manage_event_file_li from '$lib/elements/element_manage_event_file_li.svelte';
|
||||
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
// import { events_loc, events_sess, events_slct, events_trigger } from '$lib/stores/ae_events_stores';
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
// import { events_loc, events_sess, events_slct, events_trigger } from '$lib/stores/ae_events_stores';
|
||||
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
|
||||
// let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = {};
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = false;
|
||||
// let ae_triggers: key_val = {};
|
||||
// let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = {};
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = false;
|
||||
// let ae_triggers: key_val = {};
|
||||
|
||||
// WHY: v_event_file joins event_session only via event_file.event_session_id.
|
||||
// Files with for_type='event_presenter' have event_session_id=NULL on the file record
|
||||
// itself, so event_session_type_code is structurally always NULL from the API for these
|
||||
// files — even if the presenter is in a poster session. Refreshing from the API does not
|
||||
// help; the view cannot join session type for presenter-linked files.
|
||||
//
|
||||
// Solution: derive the session type via the Dexie chain:
|
||||
// presenter.event_presentation_id → presentation.event_session_id → session.type_code
|
||||
// Sessions are guaranteed in Dexie because the pres_mgmt layout loads inc_session_li:true.
|
||||
let lq__context_session_type_code = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!link_to_id) return null;
|
||||
if (link_to_type === 'event_session') {
|
||||
const session = await db_events.session.get(link_to_id);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
if (link_to_type === 'event_presentation') {
|
||||
const presentation = await db_events.presentation.get(link_to_id);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(presentation.event_session_id);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
if (link_to_type === 'event_presenter') {
|
||||
const presenter = await db_events.presenter.get(link_to_id);
|
||||
if (!presenter?.event_presentation_id) return null;
|
||||
const presentation = await db_events.presentation.get(presenter.event_presentation_id);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(presentation.event_session_id);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
// WHY: v_event_file joins event_session only via event_file.event_session_id.
|
||||
// Files with for_type='event_presenter' have event_session_id=NULL on the file record
|
||||
// itself, so event_session_type_code is structurally always NULL from the API for these
|
||||
// files — even if the presenter is in a poster session. Refreshing from the API does not
|
||||
// help; the view cannot join session type for presenter-linked files.
|
||||
//
|
||||
// Solution: derive the session type via the Dexie chain:
|
||||
// presenter.event_presentation_id → presentation.event_session_id → session.type_code
|
||||
// Sessions are guaranteed in Dexie because the pres_mgmt layout loads inc_session_li:true.
|
||||
let lq__context_session_type_code = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!link_to_id) return null;
|
||||
if (link_to_type === 'event_session') {
|
||||
const session = await db_events.session.get(link_to_id);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
if (link_to_type === 'event_presentation') {
|
||||
const presentation = await db_events.presentation.get(link_to_id);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(
|
||||
presentation.event_session_id
|
||||
);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
if (link_to_type === 'event_presenter') {
|
||||
const presenter = await db_events.presenter.get(link_to_id);
|
||||
if (!presenter?.event_presentation_id) return null;
|
||||
const presentation = await db_events.presentation.get(
|
||||
presenter.event_presentation_id
|
||||
);
|
||||
if (!presentation?.event_session_id) return null;
|
||||
const session = await db_events.session.get(
|
||||
presentation.event_session_id
|
||||
);
|
||||
return session?.type_code ?? null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
let dq__where_val: string = `for_type`;
|
||||
let dq__where_eq_val = $derived(link_to_type);
|
||||
let dq__where_for_id_eq_val = $derived(link_to_id);
|
||||
let dq__where_val: string = `for_type`;
|
||||
let dq__where_eq_val = $derived(link_to_type);
|
||||
let dq__where_for_id_eq_val = $derived(link_to_id);
|
||||
|
||||
// This should only include files that are directly linked to an object (event, location, session, presenter, etc.).
|
||||
// I am not sure why, but doing reverse() and then sortBy() seems to sort in descending order.
|
||||
let lq__event_file_obj_li = $derived(
|
||||
liveQuery(async () => {
|
||||
let results = await db_events.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((file) => file.for_id == dq__where_for_id_eq_val)
|
||||
.reverse()
|
||||
.sortBy('created_on');
|
||||
// .toArray()
|
||||
return results;
|
||||
})
|
||||
);
|
||||
// This should only include files that are directly linked to an object (event, location, session, presenter, etc.).
|
||||
// I am not sure why, but doing reverse() and then sortBy() seems to sort in descending order.
|
||||
let lq__event_file_obj_li = $derived(
|
||||
liveQuery(async () => {
|
||||
let results = await db_events.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((file) => file.for_id == dq__where_for_id_eq_val)
|
||||
.reverse()
|
||||
.sortBy('created_on');
|
||||
// .toArray()
|
||||
return results;
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
{#await lq__event_file_obj_li}
|
||||
@@ -104,8 +110,7 @@
|
||||
{container_class_li}
|
||||
{display_mode}
|
||||
context_session_type_code={$lq__context_session_type_code ?? null}
|
||||
{log_lvl}
|
||||
/>
|
||||
{log_lvl} />
|
||||
{:catch error}
|
||||
<p style="color: red;">{error.message}</p>
|
||||
{/await}
|
||||
|
||||
@@ -1,65 +1,84 @@
|
||||
<script lang="ts">
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
// import Element_data_store from '$lib/element_data_store.svelte';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
// import Element_data_store from '$lib/element_data_store.svelte';
|
||||
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
import { ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/stores/ae_stores';
|
||||
import { db_core } from '$lib/ae_core/db_core';
|
||||
import { core_func } from '$lib/ae_core/ae_core_functions';
|
||||
import { CalendarDays, Download, FolderOpen, MinusCircle, Pencil, PlusCircle, RefreshCw, Trash2 } from '@lucide/svelte';
|
||||
// export let allow_basic: boolean = false;
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
import {
|
||||
ae_loc,
|
||||
ae_sess,
|
||||
ae_api,
|
||||
ae_trig,
|
||||
slct,
|
||||
slct_trigger
|
||||
} from '$lib/stores/ae_stores';
|
||||
import { db_core } from '$lib/ae_core/db_core';
|
||||
import { core_func } from '$lib/ae_core/ae_core_functions';
|
||||
import {
|
||||
CalendarDays,
|
||||
Download,
|
||||
FolderOpen,
|
||||
MinusCircle,
|
||||
Pencil,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
// export let allow_basic: boolean = false;
|
||||
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
class_li_default?: string;
|
||||
class_li?: string;
|
||||
lq__hosted_file_obj_li: any;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
// export let allow_moderator: boolean = false;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
max_file_count?: number;
|
||||
file_type?: string; // 'image', 'video', 'audio', 'document', 'other'
|
||||
slct_hosted_file_kv?: key_val;
|
||||
slct_hosted_file_id?: any;
|
||||
slct_hosted_file_obj?: any;
|
||||
}
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
class_li_default?: string;
|
||||
class_li?: string;
|
||||
lq__hosted_file_obj_li: any;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
// export let allow_moderator: boolean = false;
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
max_file_count?: number;
|
||||
file_type?: string; // 'image', 'video', 'audio', 'document', 'other'
|
||||
slct_hosted_file_kv?: key_val;
|
||||
slct_hosted_file_id?: any;
|
||||
slct_hosted_file_obj?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
log_lvl = 0,
|
||||
class_li_default = 'flex flex-col gap-1 items-center justify-center w-fit max-w-4xl mx-auto my-1 max-h-96 overflow-auto',
|
||||
class_li = '',
|
||||
lq__hosted_file_obj_li = $bindable([]),
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
display_mode = 'default',
|
||||
max_file_count = $bindable(49),
|
||||
file_type = $bindable('all'),
|
||||
slct_hosted_file_kv = $bindable({}),
|
||||
slct_hosted_file_id = $bindable(null),
|
||||
slct_hosted_file_obj = $bindable(null)
|
||||
}: Props = $props();
|
||||
let {
|
||||
log_lvl = 0,
|
||||
class_li_default = 'flex flex-col gap-1 items-center justify-center w-fit max-w-4xl mx-auto my-1 max-h-96 overflow-auto',
|
||||
class_li = '',
|
||||
lq__hosted_file_obj_li = $bindable([]),
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
display_mode = 'default',
|
||||
max_file_count = $bindable(49),
|
||||
file_type = $bindable('all'),
|
||||
slct_hosted_file_kv = $bindable({}),
|
||||
slct_hosted_file_id = $bindable(null),
|
||||
slct_hosted_file_obj = $bindable(null)
|
||||
}: Props = $props();
|
||||
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = $state({});
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = $ae_loc.core?.show__direct_download ?? false;
|
||||
// let ae_triggers: key_val = {};
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = $state({});
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = $ae_loc.core?.show__direct_download ?? false;
|
||||
// let ae_triggers: key_val = {};
|
||||
</script>
|
||||
|
||||
<section class="{class_li_default} {class_li}">
|
||||
<h3 class="h3" class:hidden={!$lq__hosted_file_obj_li?.length || display_mode != 'default'}>
|
||||
<h3
|
||||
class="h3"
|
||||
class:hidden={!$lq__hosted_file_obj_li?.length ||
|
||||
display_mode != 'default'}>
|
||||
Manage Files:
|
||||
<span
|
||||
class="font-bold bg-success-100 px-4 border rounded-lg border-success-200"
|
||||
class="bg-success-100 border-success-200 rounded-lg border px-4 font-bold"
|
||||
title="Files for {link_to_type ?? '-- not set --'}: {link_to_id ??
|
||||
'-- not set --'} (files: {$lq__hosted_file_obj_li?.length ?? 'None'})"
|
||||
>
|
||||
'-- not set --'} (files: {$lq__hosted_file_obj_li?.length ??
|
||||
'None'})">
|
||||
<FolderOpen size="1em" class="mx-1" />
|
||||
{@html $lq__hosted_file_obj_li
|
||||
? `${$lq__hosted_file_obj_li.length}×`
|
||||
@@ -97,10 +116,9 @@
|
||||
// $slct_trigger = 'load__hosted_file_obj_li';
|
||||
// ae_tmp.show__file_li = true;
|
||||
}}
|
||||
class="btn btn-sm p-1 m-1 preset-tonal-tertiary hover:preset-tonal-warning border border-warning-500 transition hover:transition-all *:hover:inline"
|
||||
class="btn btn-sm preset-tonal-tertiary hover:preset-tonal-warning border-warning-500 m-1 border p-1 transition hover:transition-all *:hover:inline"
|
||||
class:hidden={!$ae_loc.edit_mode || !$ae_loc.authenticated_access}
|
||||
title="Refresh the list of files"
|
||||
>
|
||||
title="Refresh the list of files">
|
||||
<RefreshCw size="1em" class="m-1" />
|
||||
<div class="hidden">Files</div>
|
||||
</button>
|
||||
@@ -111,10 +129,9 @@
|
||||
console.log('*** Show Alt Download button clicked ***');
|
||||
ae_tmp.show__direct_download = !ae_tmp.show__direct_download;
|
||||
}}
|
||||
class="btn btn-sm p-1 m-1 preset-tonal-tertiary hover:preset-tonal-warning border border-warning-500 transition hover:transition-all *:hover:inline"
|
||||
class="btn btn-sm preset-tonal-tertiary hover:preset-tonal-warning border-warning-500 m-1 border p-1 transition hover:transition-all *:hover:inline"
|
||||
class:hidden={!$ae_loc.edit_mode || !$ae_loc.trusted_access}
|
||||
title="Toggle direct download link and copy link button"
|
||||
>
|
||||
title="Toggle direct download link and copy link button">
|
||||
<Download size="1em" class="m-1" />
|
||||
<div class="hidden">
|
||||
{ae_tmp.show__direct_download ? 'Alt On' : 'Alt Download Off'}
|
||||
@@ -123,8 +140,8 @@
|
||||
</div>
|
||||
|
||||
{#if $lq__hosted_file_obj_li && $lq__hosted_file_obj_li.length}
|
||||
<div class="overflow-auto w-full">
|
||||
<ol class="list-decimal list-inside">
|
||||
<div class="w-full overflow-auto">
|
||||
<ol class="list-inside list-decimal">
|
||||
{#each [...$lq__hosted_file_obj_li]
|
||||
.reverse()
|
||||
.slice(0, max_file_count) as hosted_file_obj (hosted_file_obj.hosted_file_id)}
|
||||
@@ -135,7 +152,9 @@
|
||||
onclick={() => {
|
||||
// This (uploaded_file_kv) is referenced by other AE components. Currently it is only used for the video clipper. This should be a toggle of Add/Remove.
|
||||
if (
|
||||
$ae_loc.files.uploaded_file_kv[hosted_file_obj.hosted_file_id]
|
||||
$ae_loc.files.uploaded_file_kv[
|
||||
hosted_file_obj.hosted_file_id
|
||||
]
|
||||
) {
|
||||
delete $ae_loc.files.uploaded_file_kv[
|
||||
hosted_file_obj.hosted_file_id
|
||||
@@ -144,24 +163,29 @@
|
||||
...$ae_loc.files.uploaded_file_kv
|
||||
};
|
||||
|
||||
delete slct_hosted_file_kv[hosted_file_obj.hosted_file_id];
|
||||
delete slct_hosted_file_kv[
|
||||
hosted_file_obj.hosted_file_id
|
||||
];
|
||||
slct_hosted_file_id = null;
|
||||
slct_hosted_file_obj = null;
|
||||
} else {
|
||||
$ae_loc.files.uploaded_file_kv[hosted_file_obj.hosted_file_id] =
|
||||
hosted_file_obj;
|
||||
lq__hosted_file_obj_li[hosted_file_obj.hosted_file_id] =
|
||||
hosted_file_obj;
|
||||
$ae_loc.files.uploaded_file_kv[
|
||||
hosted_file_obj.hosted_file_id
|
||||
] = hosted_file_obj;
|
||||
lq__hosted_file_obj_li[
|
||||
hosted_file_obj.hosted_file_id
|
||||
] = hosted_file_obj;
|
||||
|
||||
slct_hosted_file_kv[hosted_file_obj.hosted_file_id] =
|
||||
hosted_file_obj;
|
||||
slct_hosted_file_id = hosted_file_obj.hosted_file_id;
|
||||
slct_hosted_file_kv[
|
||||
hosted_file_obj.hosted_file_id
|
||||
] = hosted_file_obj;
|
||||
slct_hosted_file_id =
|
||||
hosted_file_obj.hosted_file_id;
|
||||
slct_hosted_file_obj = hosted_file_obj;
|
||||
}
|
||||
}}
|
||||
class="btn btn-sm preset-tonal-secondary hover:preset-filled-secondary-500"
|
||||
title="Add/Remove file to/from the locally stored uploaded file list. This is referenced by other AE components."
|
||||
>
|
||||
title="Add/Remove file to/from the locally stored uploaded file list. This is referenced by other AE components.">
|
||||
{#if $ae_loc.files.uploaded_file_kv[hosted_file_obj.hosted_file_id]}
|
||||
<MinusCircle size="1em" class="m-1" />
|
||||
<span class="hidden">Remove</span>
|
||||
@@ -186,7 +210,8 @@
|
||||
ae_promises.delete__hosted_file_obj =
|
||||
core_func.delete_ae_obj_id__hosted_file({
|
||||
api_cfg: $ae_api,
|
||||
hosted_file_id: hosted_file_obj.hosted_file_id,
|
||||
hosted_file_id:
|
||||
hosted_file_obj.hosted_file_id,
|
||||
link_to_type: link_to_type,
|
||||
link_to_id: link_to_id,
|
||||
rm_orphan: true,
|
||||
@@ -196,8 +221,7 @@
|
||||
}}
|
||||
class:hidden={!$ae_loc.administrator_access}
|
||||
class="btn btn-sm preset-tonal-secondary hover:preset-filled-secondary-500"
|
||||
title="Delete a file from the host server."
|
||||
>
|
||||
title="Delete a file from the host server.">
|
||||
<Trash2 size="1em" class="m-1" />
|
||||
<span class="hidden">Delete</span>
|
||||
</button>
|
||||
@@ -210,8 +234,7 @@
|
||||
// This should show/hide the editable fields for the file. This might need to be in a little modal.
|
||||
}}
|
||||
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500"
|
||||
title="Edit file details"
|
||||
>
|
||||
title="Edit file details">
|
||||
<Pencil size="1em" class="m-1" />
|
||||
<span class="hidden">Edit</span>
|
||||
</button>
|
||||
@@ -242,7 +265,9 @@
|
||||
{ae_util.format_bytes(hosted_file_obj.size)}
|
||||
</span>
|
||||
|
||||
<span class:hidden={!$ae_loc.edit_mode} class="text-xs text-gray-500">
|
||||
<span
|
||||
class:hidden={!$ae_loc.edit_mode}
|
||||
class="text-xs text-gray-500">
|
||||
{hosted_file_obj.hash_sha256?.slice(0, 5)}...
|
||||
</span>
|
||||
</li>
|
||||
@@ -250,7 +275,9 @@
|
||||
</ol>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="w-96 text-center text-gray-500" class:hidden={display_mode != 'default'}>
|
||||
<p
|
||||
class="w-96 text-center text-gray-500"
|
||||
class:hidden={display_mode != 'default'}>
|
||||
No files available to display
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,125 +1,125 @@
|
||||
<script lang="ts">
|
||||
// NEW NEW NEW: 2025-01-07
|
||||
import { liveQuery } from 'dexie';
|
||||
// NEW NEW NEW: 2025-01-07
|
||||
import { liveQuery } from 'dexie';
|
||||
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
// import Element_data_store from '$lib/element_data_store.svelte';
|
||||
import Element_manage_hosted_file_li from '$lib/elements/element_manage_hosted_file_li.svelte';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
// import { api } from '$lib/api';
|
||||
// import Element_data_store from '$lib/element_data_store.svelte';
|
||||
import Element_manage_hosted_file_li from '$lib/elements/element_manage_hosted_file_li.svelte';
|
||||
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
// import { ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores';
|
||||
import { db_core } from '$lib/ae_core/db_core';
|
||||
// import { events_loc, events_sess, events_slct, events_trigger } from '$lib/stores/ae_events_stores';
|
||||
// import { core_func } from '$lib/ae_core_functions';
|
||||
// import { ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores';
|
||||
import { db_core } from '$lib/ae_core/db_core';
|
||||
// import { events_loc, events_sess, events_slct, events_trigger } from '$lib/stores/ae_events_stores';
|
||||
|
||||
interface Props {
|
||||
// import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
class_li_default?: string; // |Array<string>;
|
||||
class_li?: string; // |Array<string>;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean; // Not used yet
|
||||
allow_moderator?: boolean; // Not used yet
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
max_file_count?: number;
|
||||
file_type?: string; // 'image', 'video', 'audio', 'document', 'other'
|
||||
slct_hosted_file_kv?: key_val;
|
||||
slct_hosted_file_id?: any;
|
||||
slct_hosted_file_obj?: any;
|
||||
}
|
||||
interface Props {
|
||||
// import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
class_li_default?: string; // |Array<string>;
|
||||
class_li?: string; // |Array<string>;
|
||||
link_to_type: string;
|
||||
link_to_id: string;
|
||||
allow_basic?: boolean; // Not used yet
|
||||
allow_moderator?: boolean; // Not used yet
|
||||
display_mode?: string; // 'default', 'compact', 'minimal', 'launcher'
|
||||
max_file_count?: number;
|
||||
file_type?: string; // 'image', 'video', 'audio', 'document', 'other'
|
||||
slct_hosted_file_kv?: key_val;
|
||||
slct_hosted_file_id?: any;
|
||||
slct_hosted_file_obj?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
class_li_default,
|
||||
class_li,
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default',
|
||||
max_file_count = $bindable(49),
|
||||
file_type = $bindable('all'),
|
||||
slct_hosted_file_kv = $bindable({}),
|
||||
slct_hosted_file_id = $bindable(null),
|
||||
slct_hosted_file_obj = $bindable(null)
|
||||
}: Props = $props();
|
||||
let {
|
||||
class_li_default,
|
||||
class_li,
|
||||
link_to_type,
|
||||
link_to_id,
|
||||
allow_basic = false,
|
||||
allow_moderator = false,
|
||||
display_mode = 'default',
|
||||
max_file_count = $bindable(49),
|
||||
file_type = $bindable('all'),
|
||||
slct_hosted_file_kv = $bindable({}),
|
||||
slct_hosted_file_id = $bindable(null),
|
||||
slct_hosted_file_obj = $bindable(null)
|
||||
}: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
console.log(`HERE HERE HERE HERE: link_to_type: ${link_to_type} link_to_id: ${link_to_id}`);
|
||||
});
|
||||
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
// let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = {};
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = false;
|
||||
// let ae_triggers: key_val = {};
|
||||
|
||||
let dq__where_val = $derived(`${link_to_type}_id`); // no more _random ???
|
||||
let dq__where_eq_val = $derived(link_to_id);
|
||||
|
||||
// This should include all files that are associated with an object (event, location, session, presenter, etc.)
|
||||
// I am not sure why, but doing reverse() and then sortBy() seems to sort in descending order.
|
||||
let lq__hosted_file_obj_li = $derived(
|
||||
liveQuery(async () => {
|
||||
// console.log(`dq__where_val: ${dq__where_val}`);
|
||||
// console.log(`dq__where_eq_val: ${dq__where_eq_val}`);
|
||||
let results = null;
|
||||
if (file_type == 'all' || !file_type) {
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.sortBy('created_on');
|
||||
} else if (file_type == 'video') {
|
||||
// Handle video/mp4, video/mov, video/webm. If the content type is prefixed with "video/", then it is a video file.
|
||||
let extension = 'mp4';
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
// .and((x) => (x.extension == extension))
|
||||
// .and((x) => (x.content_type == `video/${extension}`))
|
||||
.and((x) => x.content_type?.startsWith('video/') === true)
|
||||
// .reverse()
|
||||
.sortBy('created_on');
|
||||
// .toArray()
|
||||
} else if (file_type == 'audio') {
|
||||
// Handle audio/mp3, audio/wav, audio/ogg. If the content type is prefixed with "audio/", then it is an audio file.
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((x) => x.content_type?.startsWith('audio/') === true)
|
||||
.sortBy('created_on');
|
||||
} else if (file_type == 'image') {
|
||||
// Handle image/jpeg, image/png, image/gif. If the content type is prefixed with "image/", then it is an image file.
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((x) => x.content_type?.startsWith('image/') === true)
|
||||
.sortBy('created_on');
|
||||
} else if (file_type == 'document') {
|
||||
// Handle application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation. If the content type is prefixed with "application/", then it is a document file.
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((x) => x.content_type?.startsWith('application/') === true)
|
||||
.sortBy('created_on');
|
||||
} else {
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
// .reverse()
|
||||
.sortBy('created_on');
|
||||
}
|
||||
|
||||
return results;
|
||||
})
|
||||
$effect(() => {
|
||||
console.log(
|
||||
`HERE HERE HERE HERE: link_to_type: ${link_to_type} link_to_id: ${link_to_id}`
|
||||
);
|
||||
});
|
||||
|
||||
// export let show_convert_btn: null|boolean = null;
|
||||
|
||||
// let ae_placeholder_li: key_val = {};
|
||||
// let ae_promises: key_val = {};
|
||||
let ae_tmp: key_val = {};
|
||||
ae_tmp.show__file_li = true;
|
||||
ae_tmp.show__direct_download = false;
|
||||
// let ae_triggers: key_val = {};
|
||||
|
||||
let dq__where_val = $derived(`${link_to_type}_id`); // no more _random ???
|
||||
let dq__where_eq_val = $derived(link_to_id);
|
||||
|
||||
// This should include all files that are associated with an object (event, location, session, presenter, etc.)
|
||||
// I am not sure why, but doing reverse() and then sortBy() seems to sort in descending order.
|
||||
let lq__hosted_file_obj_li = $derived(
|
||||
liveQuery(async () => {
|
||||
// console.log(`dq__where_val: ${dq__where_val}`);
|
||||
// console.log(`dq__where_eq_val: ${dq__where_eq_val}`);
|
||||
let results = null;
|
||||
if (file_type == 'all' || !file_type) {
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.sortBy('created_on');
|
||||
} else if (file_type == 'video') {
|
||||
// Handle video/mp4, video/mov, video/webm. If the content type is prefixed with "video/", then it is a video file.
|
||||
let extension = 'mp4';
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
// .and((x) => (x.extension == extension))
|
||||
// .and((x) => (x.content_type == `video/${extension}`))
|
||||
.and((x) => x.content_type?.startsWith('video/') === true)
|
||||
// .reverse()
|
||||
.sortBy('created_on');
|
||||
// .toArray()
|
||||
} else if (file_type == 'audio') {
|
||||
// Handle audio/mp3, audio/wav, audio/ogg. If the content type is prefixed with "audio/", then it is an audio file.
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((x) => x.content_type?.startsWith('audio/') === true)
|
||||
.sortBy('created_on');
|
||||
} else if (file_type == 'image') {
|
||||
// Handle image/jpeg, image/png, image/gif. If the content type is prefixed with "image/", then it is an image file.
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((x) => x.content_type?.startsWith('image/') === true)
|
||||
.sortBy('created_on');
|
||||
} else if (file_type == 'document') {
|
||||
// Handle application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation. If the content type is prefixed with "application/", then it is a document file.
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
.and((x) => x.content_type?.startsWith('application/') === true)
|
||||
.sortBy('created_on');
|
||||
} else {
|
||||
results = await db_core.file
|
||||
.where(dq__where_val)
|
||||
.equals(dq__where_eq_val)
|
||||
// .reverse()
|
||||
.sortBy('created_on');
|
||||
}
|
||||
|
||||
return results;
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
{#if lq__hosted_file_obj_li}
|
||||
<Element_manage_hosted_file_li
|
||||
{link_to_type}
|
||||
@@ -132,8 +132,7 @@
|
||||
bind:file_type
|
||||
bind:slct_hosted_file_kv
|
||||
bind:slct_hosted_file_id
|
||||
bind:slct_hosted_file_obj
|
||||
/>
|
||||
bind:slct_hosted_file_obj />
|
||||
<!-- allow_basic={allow_basic} -->
|
||||
<!-- allow_moderator={allow_moderator} -->
|
||||
{:else}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
|
||||
// Should these slct_* be exported???
|
||||
let slct_obj_id = $state<any>(null);
|
||||
let slct_obj_li_type = '';
|
||||
let slct_obj_type = $state<string | null>(null);
|
||||
// Should these slct_* be exported???
|
||||
let slct_obj_id = $state<any>(null);
|
||||
let slct_obj_li_type = '';
|
||||
let slct_obj_type = $state<string | null>(null);
|
||||
|
||||
interface Props {
|
||||
row_header?: boolean;
|
||||
primary_obj_li_type?: string; // account, person, user, event, event_session, membership_person
|
||||
obj?: any;
|
||||
interface Props {
|
||||
row_header?: boolean;
|
||||
primary_obj_li_type?: string; // account, person, user, event, event_session, membership_person
|
||||
obj?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
row_header = false,
|
||||
primary_obj_li_type = $bindable(slct_obj_li_type),
|
||||
obj = null
|
||||
}: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element Object Table Row');
|
||||
|
||||
if (obj) {
|
||||
console.log('Table Row Object:', obj);
|
||||
console.log(typeof obj);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
let {
|
||||
row_header = false,
|
||||
primary_obj_li_type = $bindable(slct_obj_li_type),
|
||||
obj = null
|
||||
}: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element Object Table Row');
|
||||
|
||||
if (obj) {
|
||||
console.log('Table Row Object:', obj);
|
||||
console.log(typeof obj);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
@@ -49,8 +49,10 @@
|
||||
data-obj_type={primary_obj_li_type}
|
||||
data-obj_prop_name={obj_prop_name}
|
||||
onclick={() =>
|
||||
(primary_obj_li_type = obj_prop_name.replace('_id_random', ''))}
|
||||
>
|
||||
(primary_obj_li_type = obj_prop_name.replace(
|
||||
'_id_random',
|
||||
''
|
||||
))}>
|
||||
{ae_util.set_obj_prop_display_name({
|
||||
prop_name: obj_prop_name,
|
||||
obj_type: primary_obj_li_type
|
||||
@@ -61,14 +63,19 @@
|
||||
data-obj_type={primary_obj_li_type}
|
||||
data-obj_prop_name={obj_prop_name}
|
||||
onclick={() => {
|
||||
slct_obj_type = obj_prop_name.replace('_id_random', '');
|
||||
slct_obj_type = obj_prop_name.replace(
|
||||
'_id_random',
|
||||
''
|
||||
);
|
||||
slct_obj_id = obj_prop_value;
|
||||
}}
|
||||
onkeypress={() => {
|
||||
slct_obj_type = obj_prop_name.replace('_id_random', '');
|
||||
slct_obj_type = obj_prop_name.replace(
|
||||
'_id_random',
|
||||
''
|
||||
);
|
||||
slct_obj_id = obj_prop_value;
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<!-- {obj_prop_value} -->
|
||||
|
||||
<!-- {#if (obj_prop_value && obj_prop_value.length > 25)}
|
||||
@@ -78,8 +85,7 @@
|
||||
<a
|
||||
href="/{ae_util.return_obj_type_path({
|
||||
obj_type_prop_name: obj_prop_name
|
||||
})}/{obj_prop_value}"
|
||||
>
|
||||
})}/{obj_prop_value}">
|
||||
{String(obj_prop_value).substring(0, 25)}
|
||||
</a>
|
||||
{:else}
|
||||
@@ -94,8 +100,10 @@
|
||||
data-obj_type={primary_obj_li_type}
|
||||
data-obj_prop_name={obj_prop_name}
|
||||
onclick={() =>
|
||||
(primary_obj_li_type = obj_prop_name.replaceAll('[URL]', ''))}
|
||||
>
|
||||
(primary_obj_li_type = obj_prop_name.replaceAll(
|
||||
'[URL]',
|
||||
''
|
||||
))}>
|
||||
{ae_util.set_obj_prop_display_name({
|
||||
prop_name: obj_prop_name.replaceAll('[URL]', ''),
|
||||
obj_type: primary_obj_li_type
|
||||
@@ -106,26 +114,36 @@
|
||||
data-obj_type={primary_obj_li_type}
|
||||
data-obj_prop_name={obj_prop_name}
|
||||
onclick={() => {
|
||||
slct_obj_type = obj_prop_name.replaceAll('[URL]', '');
|
||||
slct_obj_type = obj_prop_name.replaceAll(
|
||||
'[URL]',
|
||||
''
|
||||
);
|
||||
slct_obj_id = obj_prop_value;
|
||||
}}
|
||||
onkeypress={() => {
|
||||
slct_obj_type = obj_prop_name.replaceAll('[URL]', '');
|
||||
slct_obj_type = obj_prop_name.replaceAll(
|
||||
'[URL]',
|
||||
''
|
||||
);
|
||||
slct_obj_id = obj_prop_value;
|
||||
}}
|
||||
>
|
||||
<a href={String(obj_prop_value)}>{String(obj_prop_value)}</a>
|
||||
}}>
|
||||
<a href={String(obj_prop_value)}
|
||||
>{String(obj_prop_value)}</a>
|
||||
</td>
|
||||
{/if}
|
||||
{:else if row_header}
|
||||
<th data-obj_type={primary_obj_li_type} data-obj_prop_name={obj_prop_name}>
|
||||
<th
|
||||
data-obj_type={primary_obj_li_type}
|
||||
data-obj_prop_name={obj_prop_name}>
|
||||
{ae_util.set_obj_prop_display_name({
|
||||
prop_name: obj_prop_name,
|
||||
obj_type: primary_obj_li_type
|
||||
})}
|
||||
</th>
|
||||
{:else}
|
||||
<td data-obj_type={primary_obj_li_type} data-obj_prop_name={obj_prop_name}>
|
||||
<td
|
||||
data-obj_type={primary_obj_li_type}
|
||||
data-obj_prop_name={obj_prop_name}>
|
||||
{#if obj_prop_value}
|
||||
{#if typeof obj_prop_value === 'string' && obj_prop_value.length > 25}
|
||||
{obj_prop_value.substring(0, 25)} ...
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/lib/elements/element_pwa_install_prompt.svelte
|
||||
*
|
||||
* Reusable PWA install nudge. Drop this anywhere in the app to surface
|
||||
* a platform-aware "Add to Home Screen" prompt.
|
||||
*
|
||||
* - Chrome / Android / Desktop Chrome: shows a button that triggers the
|
||||
* native browser install flow (via the captured beforeinstallprompt event).
|
||||
* - iOS Safari: shows manual step-by-step "Share → Add to Home Screen" instructions
|
||||
* since iOS does not support the beforeinstallprompt API.
|
||||
* - Already installed (standalone mode) or dismissed: renders nothing.
|
||||
*
|
||||
* The pwa_install singleton must be initialised first (done in root +layout.svelte).
|
||||
*/
|
||||
/**
|
||||
* src/lib/elements/element_pwa_install_prompt.svelte
|
||||
*
|
||||
* Reusable PWA install nudge. Drop this anywhere in the app to surface
|
||||
* a platform-aware "Add to Home Screen" prompt.
|
||||
*
|
||||
* - Chrome / Android / Desktop Chrome: shows a button that triggers the
|
||||
* native browser install flow (via the captured beforeinstallprompt event).
|
||||
* - iOS Safari: shows manual step-by-step "Share → Add to Home Screen" instructions
|
||||
* since iOS does not support the beforeinstallprompt API.
|
||||
* - Already installed (standalone mode) or dismissed: renders nothing.
|
||||
*
|
||||
* The pwa_install singleton must be initialised first (done in root +layout.svelte).
|
||||
*/
|
||||
import { Download, Plus, Share2, Smartphone, X } from '@lucide/svelte';
|
||||
import { pwa_install } from '$lib/pwa/pwa_install.svelte';
|
||||
import { pwa_install } from '$lib/pwa/pwa_install.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
let { class: extra_class = '' }: Props = $props();
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
let { class: extra_class = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if pwa_install.should_show}
|
||||
<div
|
||||
class="relative w-full rounded-2xl overflow-hidden
|
||||
border border-primary-500/25
|
||||
bg-primary-500/8 dark:bg-primary-500/12
|
||||
{extra_class}"
|
||||
>
|
||||
class="border-primary-500/25 bg-primary-500/8 dark:bg-primary-500/12 relative
|
||||
w-full overflow-hidden
|
||||
rounded-2xl border
|
||||
{extra_class}">
|
||||
<!-- Dismiss button -->
|
||||
<button
|
||||
class="absolute top-2 right-2 p-2 rounded-lg
|
||||
opacity-40 hover:opacity-90
|
||||
hover:bg-surface-500/10
|
||||
transition-opacity"
|
||||
class="hover:bg-surface-500/10 absolute top-2 right-2 rounded-lg
|
||||
p-2 opacity-40
|
||||
transition-opacity
|
||||
hover:opacity-90"
|
||||
onclick={() => pwa_install.dismiss()}
|
||||
aria-label="Dismiss install prompt"
|
||||
>
|
||||
aria-label="Dismiss install prompt">
|
||||
<X size="1rem" />
|
||||
</button>
|
||||
|
||||
<div class="p-4 pr-10 space-y-3">
|
||||
<div class="space-y-3 p-4 pr-10">
|
||||
<!-- Header row: icon + text -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="shrink-0 p-2.5 rounded-xl bg-primary-500/15 text-primary-500">
|
||||
<div
|
||||
class="bg-primary-500/15 text-primary-500 shrink-0 rounded-xl p-2.5">
|
||||
<Smartphone size="1.35em" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-sm leading-tight">Install Aether Leads</p>
|
||||
<p class="text-xs opacity-55 leading-snug mt-0.5">
|
||||
<p class="text-sm leading-tight font-bold">
|
||||
Install Aether Leads
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs leading-snug opacity-55">
|
||||
Add to your home screen for the best mobile experience
|
||||
</p>
|
||||
</div>
|
||||
@@ -59,8 +60,7 @@ import { Download, Plus, Share2, Smartphone, X } from '@lucide/svelte';
|
||||
<!-- Chrome / Android / Desktop Chrome — native one-tap install -->
|
||||
<button
|
||||
class="btn preset-filled-primary w-full"
|
||||
onclick={() => pwa_install.prompt()}
|
||||
>
|
||||
onclick={() => pwa_install.prompt()}>
|
||||
<Download size="1em" />
|
||||
<span>Add to Home Screen</span>
|
||||
</button>
|
||||
@@ -68,22 +68,30 @@ import { Download, Plus, Share2, Smartphone, X } from '@lucide/svelte';
|
||||
<!-- iOS Safari — manual instructions (no API available) -->
|
||||
<ol class="space-y-2 text-sm">
|
||||
<li class="flex items-center gap-2.5">
|
||||
<span class="shrink-0 flex items-center justify-center w-5 h-5 rounded-full
|
||||
bg-primary-500/20 text-primary-600 dark:text-primary-300
|
||||
text-[10px] font-bold leading-none">1</span>
|
||||
<span
|
||||
class="bg-primary-500/20 text-primary-600 dark:text-primary-300 flex h-5 w-5 shrink-0
|
||||
items-center justify-center rounded-full
|
||||
text-[10px] leading-none font-bold"
|
||||
>1</span>
|
||||
<span class="opacity-75">
|
||||
Tap the
|
||||
<Share2 size="0.85em" class="inline align-middle mx-0.5 text-primary-500" />
|
||||
<Share2
|
||||
size="0.85em"
|
||||
class="text-primary-500 mx-0.5 inline align-middle" />
|
||||
<strong>Share</strong> button in Safari
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2.5">
|
||||
<span class="shrink-0 flex items-center justify-center w-5 h-5 rounded-full
|
||||
bg-primary-500/20 text-primary-600 dark:text-primary-300
|
||||
text-[10px] font-bold leading-none">2</span>
|
||||
<span
|
||||
class="bg-primary-500/20 text-primary-600 dark:text-primary-300 flex h-5 w-5 shrink-0
|
||||
items-center justify-center rounded-full
|
||||
text-[10px] leading-none font-bold"
|
||||
>2</span>
|
||||
<span class="opacity-75">
|
||||
Tap <strong>Add to Home Screen</strong>
|
||||
<Plus size="0.85em" class="inline align-middle mx-0.5 text-primary-500" />
|
||||
<Plus
|
||||
size="0.85em"
|
||||
class="text-primary-500 mx-0.5 inline align-middle" />
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -1,186 +1,206 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/lib/elements/element_qr_scanner.svelte
|
||||
* QR Scanner v3 — Svelte 5 runes, auto-starts, no manual permission step.
|
||||
*
|
||||
* html5-qrcode's .start() handles camera permission internally.
|
||||
* A unique viewfinder ID is generated per instance so multiple scanners
|
||||
* can coexist on the same page without collision.
|
||||
*
|
||||
* Props:
|
||||
* start_qr_scanner (bindable) — true = scan, false = stop
|
||||
* on_qr_scan_result — callback fired with { detail: { result, entry_method } }
|
||||
* qr_fps — scan frames per second (default 10)
|
||||
* qr_facing_mode — 'environment' (rear) or 'user' (front)
|
||||
*/
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
||||
import { RefreshCw } from '@lucide/svelte';
|
||||
/**
|
||||
* src/lib/elements/element_qr_scanner.svelte
|
||||
* QR Scanner v3 — Svelte 5 runes, auto-starts, no manual permission step.
|
||||
*
|
||||
* html5-qrcode's .start() handles camera permission internally.
|
||||
* A unique viewfinder ID is generated per instance so multiple scanners
|
||||
* can coexist on the same page without collision.
|
||||
*
|
||||
* Props:
|
||||
* start_qr_scanner (bindable) — true = scan, false = stop
|
||||
* on_qr_scan_result — callback fired with { detail: { result, entry_method } }
|
||||
* qr_fps — scan frames per second (default 10)
|
||||
* qr_facing_mode — 'environment' (rear) or 'user' (front)
|
||||
*/
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
||||
import { RefreshCw } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
start_qr_scanner?: boolean;
|
||||
on_qr_scan_result?: (event: { detail: { result: string; entry_method: string } }) => void;
|
||||
qr_fps?: number;
|
||||
qr_facing_mode?: string;
|
||||
}
|
||||
interface Props {
|
||||
start_qr_scanner?: boolean;
|
||||
on_qr_scan_result?: (event: {
|
||||
detail: { result: string; entry_method: string };
|
||||
}) => void;
|
||||
qr_fps?: number;
|
||||
qr_facing_mode?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
start_qr_scanner = $bindable(true),
|
||||
on_qr_scan_result,
|
||||
qr_fps = 25,
|
||||
qr_facing_mode = 'environment'
|
||||
}: Props = $props();
|
||||
let {
|
||||
start_qr_scanner = $bindable(true),
|
||||
on_qr_scan_result,
|
||||
qr_fps = 25,
|
||||
qr_facing_mode = 'environment'
|
||||
}: Props = $props();
|
||||
|
||||
// Unique DOM ID per instance — prevents conflicts when multiple scanners mount
|
||||
const viewfinder_id = `qr_vf_${Math.random().toString(36).substring(2, 9)}`;
|
||||
// Unique DOM ID per instance — prevents conflicts when multiple scanners mount
|
||||
const viewfinder_id = `qr_vf_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
let scanner: Html5Qrcode | null = null;
|
||||
let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle');
|
||||
let error_msg = $state('');
|
||||
let start_timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let scanner: Html5Qrcode | null = null;
|
||||
let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle');
|
||||
let error_msg = $state('');
|
||||
let start_timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// React to start_qr_scanner prop changes from the parent
|
||||
$effect(() => {
|
||||
const should_scan = start_qr_scanner;
|
||||
// React to start_qr_scanner prop changes from the parent
|
||||
$effect(() => {
|
||||
const should_scan = start_qr_scanner;
|
||||
|
||||
untrack(() => {
|
||||
if (should_scan && (status === 'idle' || status === 'error')) {
|
||||
start_scanning();
|
||||
} else if (!should_scan && status === 'scanning') {
|
||||
stop_scanning();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clear_start_timeout();
|
||||
stop_scanning();
|
||||
});
|
||||
|
||||
function clear_start_timeout() {
|
||||
if (start_timeout !== null) {
|
||||
clearTimeout(start_timeout);
|
||||
start_timeout = null;
|
||||
untrack(() => {
|
||||
if (should_scan && (status === 'idle' || status === 'error')) {
|
||||
start_scanning();
|
||||
} else if (!should_scan && status === 'scanning') {
|
||||
stop_scanning();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clear_start_timeout();
|
||||
stop_scanning();
|
||||
});
|
||||
|
||||
function clear_start_timeout() {
|
||||
if (start_timeout !== null) {
|
||||
clearTimeout(start_timeout);
|
||||
start_timeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function start_scanning() {
|
||||
if (status === 'starting' || status === 'scanning') return;
|
||||
status = 'starting';
|
||||
error_msg = '';
|
||||
async function start_scanning() {
|
||||
if (status === 'starting' || status === 'scanning') return;
|
||||
status = 'starting';
|
||||
error_msg = '';
|
||||
|
||||
// If the camera hasn't started within 7 seconds, show a helpful nudge.
|
||||
// Common causes: permission dialog left open, camera in use by another app, slow device.
|
||||
start_timeout = setTimeout(() => {
|
||||
if (status === 'starting') {
|
||||
status = 'error';
|
||||
error_msg = 'Camera is taking too long to start. Try refreshing the page, or check that no other app is using your camera.';
|
||||
}
|
||||
}, 7000);
|
||||
|
||||
try {
|
||||
scanner = new Html5Qrcode(viewfinder_id, {
|
||||
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
|
||||
verbose: false
|
||||
});
|
||||
|
||||
await scanner.start(
|
||||
{ facingMode: qr_facing_mode },
|
||||
{
|
||||
fps: qr_fps,
|
||||
// Use a percentage of the viewfinder so it scales on any screen size
|
||||
qrbox: (w: number, h: number) => {
|
||||
const side = Math.floor(Math.min(w, h) * 0.88);
|
||||
return { width: side, height: side };
|
||||
},
|
||||
// Use native BarcodeDetector API on Chrome/Edge — significantly faster
|
||||
// than the JS ZXing fallback used on Firefox/older Safari.
|
||||
// Cast: experimentalFeatures exists at runtime but is missing from the
|
||||
// html5-qrcode TypeScript type definitions for this version.
|
||||
...({ experimentalFeatures: { useBarCodeDetectorIfSupported: true } } as { experimentalFeatures: { useBarCodeDetectorIfSupported: boolean } })
|
||||
},
|
||||
on_scan_success,
|
||||
on_scan_error
|
||||
);
|
||||
|
||||
clear_start_timeout();
|
||||
status = 'scanning';
|
||||
} catch (e: any) {
|
||||
clear_start_timeout();
|
||||
// If the camera hasn't started within 7 seconds, show a helpful nudge.
|
||||
// Common causes: permission dialog left open, camera in use by another app, slow device.
|
||||
start_timeout = setTimeout(() => {
|
||||
if (status === 'starting') {
|
||||
status = 'error';
|
||||
if (e?.name === 'NotAllowedError') {
|
||||
error_msg = 'Camera access denied. Please allow camera in your browser settings and try again.';
|
||||
} else {
|
||||
error_msg = 'Could not start camera. Please try again.';
|
||||
}
|
||||
console.warn('[QR v3] Scanner start failed:', e);
|
||||
error_msg =
|
||||
'Camera is taking too long to start. Try refreshing the page, or check that no other app is using your camera.';
|
||||
}
|
||||
}
|
||||
}, 7000);
|
||||
|
||||
async function stop_scanning() {
|
||||
clear_start_timeout();
|
||||
if (!scanner) return;
|
||||
const s = scanner;
|
||||
scanner = null;
|
||||
try {
|
||||
await s.stop();
|
||||
await s.clear();
|
||||
} catch {
|
||||
// Ignore cleanup errors — component may be unmounting
|
||||
}
|
||||
status = 'idle';
|
||||
}
|
||||
|
||||
function on_scan_success(decoded_text: string) {
|
||||
// Stop scanning before notifying parent so the camera shuts down cleanly
|
||||
stop_scanning().then(() => {
|
||||
start_qr_scanner = false;
|
||||
try {
|
||||
scanner = new Html5Qrcode(viewfinder_id, {
|
||||
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
|
||||
verbose: false
|
||||
});
|
||||
|
||||
if (on_qr_scan_result) {
|
||||
on_qr_scan_result({ detail: { result: decoded_text, entry_method: 'QR' } });
|
||||
}
|
||||
}
|
||||
await scanner.start(
|
||||
{ facingMode: qr_facing_mode },
|
||||
{
|
||||
fps: qr_fps,
|
||||
// Use a percentage of the viewfinder so it scales on any screen size
|
||||
qrbox: (w: number, h: number) => {
|
||||
const side = Math.floor(Math.min(w, h) * 0.88);
|
||||
return { width: side, height: side };
|
||||
},
|
||||
// Use native BarcodeDetector API on Chrome/Edge — significantly faster
|
||||
// than the JS ZXing fallback used on Firefox/older Safari.
|
||||
// Cast: experimentalFeatures exists at runtime but is missing from the
|
||||
// html5-qrcode TypeScript type definitions for this version.
|
||||
...({
|
||||
experimentalFeatures: {
|
||||
useBarCodeDetectorIfSupported: true
|
||||
}
|
||||
} as {
|
||||
experimentalFeatures: {
|
||||
useBarCodeDetectorIfSupported: boolean;
|
||||
};
|
||||
})
|
||||
},
|
||||
on_scan_success,
|
||||
on_scan_error
|
||||
);
|
||||
|
||||
function on_scan_error(_msg: string) {
|
||||
// Called on every frame that doesn't contain a QR code — expected, not an error
|
||||
clear_start_timeout();
|
||||
status = 'scanning';
|
||||
} catch (e: any) {
|
||||
clear_start_timeout();
|
||||
status = 'error';
|
||||
if (e?.name === 'NotAllowedError') {
|
||||
error_msg =
|
||||
'Camera access denied. Please allow camera in your browser settings and try again.';
|
||||
} else {
|
||||
error_msg = 'Could not start camera. Please try again.';
|
||||
}
|
||||
console.warn('[QR v3] Scanner start failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function stop_scanning() {
|
||||
clear_start_timeout();
|
||||
if (!scanner) return;
|
||||
const s = scanner;
|
||||
scanner = null;
|
||||
try {
|
||||
await s.stop();
|
||||
await s.clear();
|
||||
} catch {
|
||||
// Ignore cleanup errors — component may be unmounting
|
||||
}
|
||||
status = 'idle';
|
||||
}
|
||||
|
||||
function on_scan_success(decoded_text: string) {
|
||||
// Stop scanning before notifying parent so the camera shuts down cleanly
|
||||
stop_scanning().then(() => {
|
||||
start_qr_scanner = false;
|
||||
});
|
||||
|
||||
if (on_qr_scan_result) {
|
||||
on_qr_scan_result({
|
||||
detail: { result: decoded_text, entry_method: 'QR' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function on_scan_error(_msg: string) {
|
||||
// Called on every frame that doesn't contain a QR code — expected, not an error
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* html5-qrcode injects a <video> with inline width/height. Override with cover so the
|
||||
camera stream fills the container completely on any camera aspect ratio (4:3, 16:9, etc.).
|
||||
Without this, portrait-mode mobile cameras leave a gray letterbox at the bottom. */
|
||||
:global(.qr-scanner-v3 video) {
|
||||
object-fit: cover !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Viewfinder fills whatever container the parent provides -->
|
||||
<div class="qr-scanner-v3 w-full h-full flex flex-col items-center justify-center">
|
||||
<div id={viewfinder_id} class="w-full h-full"></div>
|
||||
<div
|
||||
class="qr-scanner-v3 flex h-full w-full flex-col items-center justify-center">
|
||||
<div id={viewfinder_id} class="h-full w-full"></div>
|
||||
|
||||
{#if status === 'starting'}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/30 rounded-xl">
|
||||
<span class="bg-black/60 text-white text-sm font-semibold px-4 py-2 rounded-full animate-pulse shadow-lg">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center rounded-xl bg-black/30">
|
||||
<span
|
||||
class="animate-pulse rounded-full bg-black/60 px-4 py-2 text-sm font-semibold text-white shadow-lg">
|
||||
Starting camera...
|
||||
</span>
|
||||
</div>
|
||||
{:else if status === 'error'}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-5 bg-black/75 rounded-xl p-6 text-center">
|
||||
<p class="text-white text-sm font-semibold leading-snug drop-shadow">{error_msg}</p>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-5 rounded-xl bg-black/75 p-6 text-center">
|
||||
<p
|
||||
class="text-sm leading-snug font-semibold text-white drop-shadow">
|
||||
{error_msg}
|
||||
</p>
|
||||
<!-- bg-white + dark text: always readable on the dark camera overlay regardless of theme.
|
||||
preset-filled-primary is theme-adaptive and has poor contrast on black/75 backgrounds. -->
|
||||
<button
|
||||
type="button"
|
||||
class="bg-white text-surface-950 hover:bg-surface-100 font-bold text-base px-8 py-3 rounded-xl shadow-lg transition-colors cursor-pointer flex items-center gap-2"
|
||||
onclick={start_scanning}
|
||||
>
|
||||
class="text-surface-950 hover:bg-surface-100 flex cursor-pointer items-center gap-2 rounded-xl bg-white px-8 py-3 text-base font-bold shadow-lg transition-colors"
|
||||
onclick={start_scanning}>
|
||||
<RefreshCw size="1.2em" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* html5-qrcode injects a <video> with inline width/height. Override with cover so the
|
||||
camera stream fills the container completely on any camera aspect ratio (4:3, 16:9, etc.).
|
||||
Without this, portrait-mode mobile cameras leave a gray letterbox at the bottom. */
|
||||
:global(.qr-scanner-v3 video) {
|
||||
object-fit: cover !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,107 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// *** Import Aether related
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import Element_obj_tbl_row from '$lib/elements/element_obj_tbl_row.svelte';
|
||||
import { post_object } from '$lib/ae_api/api_post_object';
|
||||
// *** Import Aether related
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import Element_obj_tbl_row from '$lib/elements/element_obj_tbl_row.svelte';
|
||||
import { post_object } from '$lib/ae_api/api_post_object';
|
||||
|
||||
interface Props {
|
||||
// *** Export/Exposed variables and functions for component
|
||||
api_cfg: any;
|
||||
show_textarea?: boolean;
|
||||
button_label?: string;
|
||||
show_record_count?: boolean;
|
||||
remove_breaks?: boolean;
|
||||
run_on_load?: boolean;
|
||||
sql_statement: string;
|
||||
sql_data?: any;
|
||||
as_list?: boolean;
|
||||
log_lvl?: number;
|
||||
interface Props {
|
||||
// *** Export/Exposed variables and functions for component
|
||||
api_cfg: any;
|
||||
show_textarea?: boolean;
|
||||
button_label?: string;
|
||||
show_record_count?: boolean;
|
||||
remove_breaks?: boolean;
|
||||
run_on_load?: boolean;
|
||||
sql_statement: string;
|
||||
sql_data?: any;
|
||||
as_list?: boolean;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
api_cfg,
|
||||
show_textarea = true,
|
||||
button_label = 'Run SQL!',
|
||||
show_record_count = true,
|
||||
remove_breaks = false,
|
||||
run_on_load = false,
|
||||
sql_statement = $bindable(),
|
||||
sql_data = null,
|
||||
as_list = false,
|
||||
log_lvl = 0
|
||||
}: Props = $props();
|
||||
|
||||
// *** Set initial variables
|
||||
let ae_promises: key_val = $state({});
|
||||
let sql_qry_result: any = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element SQL Query');
|
||||
|
||||
if (run_on_load) {
|
||||
console.log('Run On Load');
|
||||
let result = handle_run_sql(
|
||||
sql_statement,
|
||||
sql_data,
|
||||
as_list,
|
||||
log_lvl
|
||||
).then((qry_result) => {
|
||||
console.log('SQL Query Result:', qry_result);
|
||||
sql_qry_result = qry_result;
|
||||
return qry_result;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// const dispatch = createEventDispatcher();
|
||||
|
||||
async function handle_run_sql(
|
||||
qry: string,
|
||||
data: any,
|
||||
as_list = false,
|
||||
log_lvl = 0
|
||||
) {
|
||||
console.log('*** handle_run_sql() ***');
|
||||
|
||||
let sql_qry_data: key_val = {};
|
||||
|
||||
let endpoint = '/sql/select';
|
||||
let params: key_val = {};
|
||||
if (as_list) {
|
||||
params['as_list'] = true;
|
||||
}
|
||||
if (log_lvl) {
|
||||
console.log('Params:', params);
|
||||
}
|
||||
|
||||
let {
|
||||
api_cfg,
|
||||
show_textarea = true,
|
||||
button_label = 'Run SQL!',
|
||||
show_record_count = true,
|
||||
remove_breaks = false,
|
||||
run_on_load = false,
|
||||
sql_statement = $bindable(),
|
||||
sql_data = null,
|
||||
as_list = false,
|
||||
log_lvl = 0
|
||||
}: Props = $props();
|
||||
if (qry) {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (log_lvl > 1) {
|
||||
console.log('Qry:', qry);
|
||||
}
|
||||
|
||||
// *** Set initial variables
|
||||
let ae_promises: key_val = $state({});
|
||||
let sql_qry_result: any = $state(null);
|
||||
if (remove_breaks) {
|
||||
sql_qry_data['sql_qry'] = qry.replace(/(\r\n|\n|\r)/gm, '');
|
||||
} else {
|
||||
sql_qry_data['sql_qry'] = qry;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
console.log('** Element Mounted: ** Element SQL Query');
|
||||
sql_qry_data['sql_data'] = data;
|
||||
|
||||
if (run_on_load) {
|
||||
console.log('Run On Load');
|
||||
let result = handle_run_sql(sql_statement, sql_data, as_list, log_lvl).then(
|
||||
(qry_result) => {
|
||||
console.log('SQL Query Result:', qry_result);
|
||||
sql_qry_result = qry_result;
|
||||
return qry_result;
|
||||
}
|
||||
);
|
||||
}
|
||||
if (log_lvl) {
|
||||
console.log('SQL Qry Data:', sql_qry_data);
|
||||
}
|
||||
|
||||
ae_promises.sql_qry_promise = await post_object({
|
||||
api_cfg: api_cfg,
|
||||
endpoint: endpoint,
|
||||
params: params,
|
||||
data: sql_qry_data,
|
||||
log_lvl: log_lvl
|
||||
});
|
||||
|
||||
// const dispatch = createEventDispatcher();
|
||||
|
||||
async function handle_run_sql(qry: string, data: any, as_list = false, log_lvl = 0) {
|
||||
console.log('*** handle_run_sql() ***');
|
||||
|
||||
let sql_qry_data: key_val = {};
|
||||
|
||||
let endpoint = '/sql/select';
|
||||
let params: key_val = {};
|
||||
if (as_list) {
|
||||
params['as_list'] = true;
|
||||
}
|
||||
if (log_lvl) {
|
||||
console.log('Params:', params);
|
||||
}
|
||||
|
||||
if (qry) {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (log_lvl > 1) {
|
||||
console.log('Qry:', qry);
|
||||
}
|
||||
|
||||
if (remove_breaks) {
|
||||
sql_qry_data['sql_qry'] = qry.replace(/(\r\n|\n|\r)/gm, '');
|
||||
} else {
|
||||
sql_qry_data['sql_qry'] = qry;
|
||||
}
|
||||
|
||||
sql_qry_data['sql_data'] = data;
|
||||
|
||||
if (log_lvl) {
|
||||
console.log('SQL Qry Data:', sql_qry_data);
|
||||
}
|
||||
|
||||
ae_promises.sql_qry_promise = await post_object({
|
||||
api_cfg: api_cfg,
|
||||
endpoint: endpoint,
|
||||
params: params,
|
||||
data: sql_qry_data,
|
||||
log_lvl: log_lvl
|
||||
});
|
||||
|
||||
if (log_lvl) {
|
||||
console.log('SQL Query Results', ae_promises.sql_qry_promise);
|
||||
}
|
||||
|
||||
return ae_promises.sql_qry_promise;
|
||||
if (log_lvl) {
|
||||
console.log('SQL Query Results', ae_promises.sql_qry_promise);
|
||||
}
|
||||
|
||||
return ae_promises.sql_qry_promise;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="sql_qry" class="sql_qry">
|
||||
@@ -113,10 +121,14 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
sql_qry_result = await handle_run_sql(sql_statement, sql_data, as_list, log_lvl);
|
||||
sql_qry_result = await handle_run_sql(
|
||||
sql_statement,
|
||||
sql_data,
|
||||
as_list,
|
||||
log_lvl
|
||||
);
|
||||
}}
|
||||
class="btn btn-md preset-tonal-primary hover:preset-tonal-primary border border-primary-500"
|
||||
>
|
||||
class="btn btn-md preset-tonal-primary hover:preset-tonal-primary border-primary-500 border">
|
||||
{button_label}
|
||||
</button>
|
||||
</div>
|
||||
@@ -133,15 +145,15 @@
|
||||
{:then}
|
||||
{#if sql_qry_result && sql_qry_result.length}
|
||||
<table
|
||||
class="table table-compact table-bordered table-striped min-w-min sql_qry_result text-xs"
|
||||
>
|
||||
class="table-compact table-bordered table-striped sql_qry_result table min-w-min text-xs">
|
||||
<Element_obj_tbl_row
|
||||
row_header={true}
|
||||
obj={sql_qry_result[0]}
|
||||
primary_obj_li_type=""
|
||||
/>
|
||||
primary_obj_li_type="" />
|
||||
{#each sql_qry_result as record, index (index)}
|
||||
<Element_obj_tbl_row obj={record} primary_obj_li_type="" />
|
||||
<Element_obj_tbl_row
|
||||
obj={record}
|
||||
primary_obj_li_type="" />
|
||||
{/each}
|
||||
</table>
|
||||
{:else}
|
||||
@@ -152,7 +164,7 @@
|
||||
</section>
|
||||
|
||||
<style lang="postcss">
|
||||
/* .sql_qry textarea {
|
||||
/* .sql_qry textarea {
|
||||
width: 100%;
|
||||
height: 8em;
|
||||
} */
|
||||
|
||||
@@ -1,370 +1,386 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* element_websocket.svelte — Aether WebSocket V3 Client
|
||||
*
|
||||
* PURPOSE:
|
||||
* Drop-in replacement for element_websocket_v2.svelte using the V3 protocol:
|
||||
* - URL: /v3/ws/group/{group_id}/client/{client_id}?api_key=...&jwt=...
|
||||
* - Strict WS_Message_V3 schema: { msg_type, target, cmd?, msg?, payload? }
|
||||
* - Redis-granular routing (group / direct / broadcast / echo)
|
||||
* - Automatic 45s heartbeat to maintain Redis presence TTL
|
||||
* - Auth via query params (browsers cannot set WS headers)
|
||||
*
|
||||
* EXTERNAL INTERFACE:
|
||||
* Intentionally prop-compatible with V2 so callers need minimal changes.
|
||||
* New V3-only props: api_key, jwt, x_account_id.
|
||||
*
|
||||
* SCHEMA CHANGES FROM V2:
|
||||
* - `type` field renamed to `msg_type` ('cmd'|'msg'|'heartbeat'|'presence')
|
||||
* - `target` values: 'group' (was 'all'), 'direct' (was 'dm'), 'echo', 'broadcast'
|
||||
* - `from_id` (server-set) replaces self-echo check via `client_id` in payload
|
||||
* - Do NOT send `client_id`, `group_id`, or `from_id` in message body; server fills them.
|
||||
*/
|
||||
/**
|
||||
* element_websocket.svelte — Aether WebSocket V3 Client
|
||||
*
|
||||
* PURPOSE:
|
||||
* Drop-in replacement for element_websocket_v2.svelte using the V3 protocol:
|
||||
* - URL: /v3/ws/group/{group_id}/client/{client_id}?api_key=...&jwt=...
|
||||
* - Strict WS_Message_V3 schema: { msg_type, target, cmd?, msg?, payload? }
|
||||
* - Redis-granular routing (group / direct / broadcast / echo)
|
||||
* - Automatic 45s heartbeat to maintain Redis presence TTL
|
||||
* - Auth via query params (browsers cannot set WS headers)
|
||||
*
|
||||
* EXTERNAL INTERFACE:
|
||||
* Intentionally prop-compatible with V2 so callers need minimal changes.
|
||||
* New V3-only props: api_key, jwt, x_account_id.
|
||||
*
|
||||
* SCHEMA CHANGES FROM V2:
|
||||
* - `type` field renamed to `msg_type` ('cmd'|'msg'|'heartbeat'|'presence')
|
||||
* - `target` values: 'group' (was 'all'), 'direct' (was 'dm'), 'echo', 'broadcast'
|
||||
* - `from_id` (server-set) replaces self-echo check via `client_id` in payload
|
||||
* - Do NOT send `client_id`, `group_id`, or `from_id` in message body; server fills them.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
|
||||
// Connection control
|
||||
ws_connect?: boolean;
|
||||
ws_connect_status?: null | string;
|
||||
ws_server?: string;
|
||||
ws_retry_delay?: number;
|
||||
ws_retry_count?: number;
|
||||
// Connection control
|
||||
ws_connect?: boolean;
|
||||
ws_connect_status?: null | string;
|
||||
ws_server?: string;
|
||||
ws_retry_delay?: number;
|
||||
ws_retry_count?: number;
|
||||
|
||||
// V3 Auth (passed as query params — browsers cannot set WS headers)
|
||||
api_key?: string | null;
|
||||
jwt?: string | null;
|
||||
x_account_id?: string | null;
|
||||
// V3 Auth (passed as query params — browsers cannot set WS headers)
|
||||
api_key?: string | null;
|
||||
jwt?: string | null;
|
||||
x_account_id?: string | null;
|
||||
|
||||
// Identity
|
||||
group_id?: string;
|
||||
client_id?: any;
|
||||
// Identity
|
||||
group_id?: string;
|
||||
client_id?: any;
|
||||
|
||||
// Outbound message content
|
||||
cmd?: null | string;
|
||||
msg?: null | string;
|
||||
trigger_send?: any;
|
||||
trigger_connect?: boolean;
|
||||
trigger_disconnect?: boolean;
|
||||
// Outbound message content
|
||||
cmd?: null | string;
|
||||
msg?: null | string;
|
||||
trigger_send?: any;
|
||||
trigger_connect?: boolean;
|
||||
trigger_disconnect?: boolean;
|
||||
|
||||
// Visibility flags (for debug panel)
|
||||
hide__ws_element?: boolean;
|
||||
hide__ws_form?: boolean;
|
||||
hide__ws_messages?: boolean;
|
||||
hide__ws_commands?: boolean;
|
||||
// Visibility flags (for debug panel)
|
||||
hide__ws_element?: boolean;
|
||||
hide__ws_form?: boolean;
|
||||
hide__ws_messages?: boolean;
|
||||
hide__ws_commands?: boolean;
|
||||
|
||||
// Output status bindings
|
||||
ws_conn_status?: any;
|
||||
ws_recv_status?: any;
|
||||
ws_sent_status?: any;
|
||||
}
|
||||
// Output status bindings
|
||||
ws_conn_status?: any;
|
||||
ws_recv_status?: any;
|
||||
ws_sent_status?: any;
|
||||
}
|
||||
|
||||
let {
|
||||
log_lvl = 0,
|
||||
let {
|
||||
log_lvl = 0,
|
||||
|
||||
ws_connect = $bindable(false),
|
||||
ws_connect_status = $bindable(null),
|
||||
ws_server = 'dev-api.oneskyit.com',
|
||||
ws_retry_delay = 3500,
|
||||
ws_retry_count = 0,
|
||||
ws_connect = $bindable(false),
|
||||
ws_connect_status = $bindable(null),
|
||||
ws_server = 'dev-api.oneskyit.com',
|
||||
ws_retry_delay = 3500,
|
||||
ws_retry_count = 0,
|
||||
|
||||
api_key = null,
|
||||
jwt = null,
|
||||
x_account_id = null,
|
||||
api_key = null,
|
||||
jwt = null,
|
||||
x_account_id = null,
|
||||
|
||||
group_id = $bindable('ae-grp-99'),
|
||||
client_id = $bindable(Date.now()),
|
||||
group_id = $bindable('ae-grp-99'),
|
||||
client_id = $bindable(Date.now()),
|
||||
|
||||
cmd = $bindable(null),
|
||||
msg = $bindable(null),
|
||||
trigger_send = $bindable(null),
|
||||
trigger_connect = $bindable(false),
|
||||
trigger_disconnect = $bindable(false),
|
||||
cmd = $bindable(null),
|
||||
msg = $bindable(null),
|
||||
trigger_send = $bindable(null),
|
||||
trigger_connect = $bindable(false),
|
||||
trigger_disconnect = $bindable(false),
|
||||
|
||||
hide__ws_element = $bindable(false),
|
||||
hide__ws_form = $bindable(true),
|
||||
hide__ws_messages = $bindable(false),
|
||||
hide__ws_commands = $bindable(false),
|
||||
hide__ws_element = $bindable(false),
|
||||
hide__ws_form = $bindable(true),
|
||||
hide__ws_messages = $bindable(false),
|
||||
hide__ws_commands = $bindable(false),
|
||||
|
||||
ws_conn_status = $bindable(null),
|
||||
ws_recv_status = $bindable(null),
|
||||
ws_sent_status = $bindable(null)
|
||||
}: Props = $props();
|
||||
ws_conn_status = $bindable(null),
|
||||
ws_recv_status = $bindable(null),
|
||||
ws_sent_status = $bindable(null)
|
||||
}: Props = $props();
|
||||
|
||||
// *** Internal state
|
||||
// *** Internal state
|
||||
|
||||
let ws_received_list_cmd: any[] = $state([]);
|
||||
let ws_received_list_other: any[] = $state([]);
|
||||
let ws_received_list_cmd: any[] = $state([]);
|
||||
let ws_received_list_other: any[] = $state([]);
|
||||
|
||||
let ws_group: WebSocket | null = $state(null);
|
||||
let heartbeat_interval: ReturnType<typeof setInterval> | null = null;
|
||||
let ws_group: WebSocket | null = $state(null);
|
||||
let heartbeat_interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// *** Build V3 URL with auth query params
|
||||
// *** Build V3 URL with auth query params
|
||||
|
||||
function build_ws_url(group: string, client: any): string {
|
||||
// Use wss:// in production (HTTPS), ws:// for local dev (HTTP)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const params = new URLSearchParams();
|
||||
if (api_key) params.set('api_key', api_key);
|
||||
if (jwt) params.set('jwt', jwt);
|
||||
if (x_account_id) params.set('x_account_id', x_account_id);
|
||||
const qs = params.toString();
|
||||
return `${protocol}://${ws_server}/v3/ws/group/${group}/client/${client}${qs ? '?' + qs : ''}`;
|
||||
}
|
||||
function build_ws_url(group: string, client: any): string {
|
||||
// Use wss:// in production (HTTPS), ws:// for local dev (HTTP)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const params = new URLSearchParams();
|
||||
if (api_key) params.set('api_key', api_key);
|
||||
if (jwt) params.set('jwt', jwt);
|
||||
if (x_account_id) params.set('x_account_id', x_account_id);
|
||||
const qs = params.toString();
|
||||
return `${protocol}://${ws_server}/v3/ws/group/${group}/client/${client}${qs ? '?' + qs : ''}`;
|
||||
}
|
||||
|
||||
// *** Heartbeat — keeps Redis presence TTL alive (V3 requirement)
|
||||
// *** Heartbeat — keeps Redis presence TTL alive (V3 requirement)
|
||||
|
||||
function start_heartbeat(conn: WebSocket) {
|
||||
stop_heartbeat();
|
||||
heartbeat_interval = setInterval(() => {
|
||||
if (conn.readyState === WebSocket.OPEN) {
|
||||
conn.send(JSON.stringify({ msg_type: 'heartbeat', target: 'echo' }));
|
||||
if (log_lvl) console.log('WS V3: heartbeat sent');
|
||||
}
|
||||
}, 45_000);
|
||||
}
|
||||
|
||||
function stop_heartbeat() {
|
||||
if (heartbeat_interval !== null) {
|
||||
clearInterval(heartbeat_interval);
|
||||
heartbeat_interval = null;
|
||||
function start_heartbeat(conn: WebSocket) {
|
||||
stop_heartbeat();
|
||||
heartbeat_interval = setInterval(() => {
|
||||
if (conn.readyState === WebSocket.OPEN) {
|
||||
conn.send(
|
||||
JSON.stringify({ msg_type: 'heartbeat', target: 'echo' })
|
||||
);
|
||||
if (log_lvl) console.log('WS V3: heartbeat sent');
|
||||
}
|
||||
}, 45_000);
|
||||
}
|
||||
|
||||
function stop_heartbeat() {
|
||||
if (heartbeat_interval !== null) {
|
||||
clearInterval(heartbeat_interval);
|
||||
heartbeat_interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// *** Core connection
|
||||
|
||||
function ws_connect_group_id({
|
||||
group,
|
||||
client
|
||||
}: {
|
||||
group: string;
|
||||
client: any;
|
||||
}): WebSocket {
|
||||
if (!group) {
|
||||
group = 'ae-grp-99';
|
||||
console.warn('WS V3: No group_id — using default:', group);
|
||||
}
|
||||
if (!client) {
|
||||
client = Date.now();
|
||||
console.warn('WS V3: No client_id — using timestamp:', client);
|
||||
}
|
||||
|
||||
// *** Core connection
|
||||
const url = build_ws_url(group, client);
|
||||
if (log_lvl) console.log('WS V3 connect URL:', url);
|
||||
|
||||
function ws_connect_group_id({ group, client }: { group: string; client: any }): WebSocket {
|
||||
if (!group) {
|
||||
group = 'ae-grp-99';
|
||||
console.warn('WS V3: No group_id — using default:', group);
|
||||
}
|
||||
if (!client) {
|
||||
client = Date.now();
|
||||
console.warn('WS V3: No client_id — using timestamp:', client);
|
||||
}
|
||||
const conn = new WebSocket(url);
|
||||
|
||||
const url = build_ws_url(group, client);
|
||||
if (log_lvl) console.log('WS V3 connect URL:', url);
|
||||
conn.onopen = () => {
|
||||
if (log_lvl) console.log('WS V3: connected');
|
||||
ws_connect_status = 'connected';
|
||||
ws_conn_status = 'connected';
|
||||
ws_retry_count = 0;
|
||||
|
||||
const conn = new WebSocket(url);
|
||||
|
||||
conn.onopen = () => {
|
||||
if (log_lvl) console.log('WS V3: connected');
|
||||
ws_connect_status = 'connected';
|
||||
ws_conn_status = 'connected';
|
||||
ws_retry_count = 0;
|
||||
|
||||
// Announce presence to the group
|
||||
conn.send(JSON.stringify({
|
||||
// Announce presence to the group
|
||||
conn.send(
|
||||
JSON.stringify({
|
||||
msg_type: 'msg',
|
||||
target: 'group',
|
||||
msg: `Client ${String(client).slice(-5)} connected`
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
start_heartbeat(conn);
|
||||
};
|
||||
start_heartbeat(conn);
|
||||
};
|
||||
|
||||
conn.onmessage = (event) => {
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
console.warn('WS V3: non-JSON message ignored', event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (log_lvl) console.log('WS V3: received', data);
|
||||
|
||||
// Ignore messages we sent ourselves (server stamps from_id)
|
||||
if (data.from_id && String(data.from_id) === String(client)) {
|
||||
if (log_lvl) console.log('WS V3: self-echo ignored');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore heartbeat echoes silently
|
||||
if (data.msg_type === 'heartbeat') return;
|
||||
|
||||
if (data.msg_type === 'cmd') {
|
||||
ws_received_list_cmd = [data, ...ws_received_list_cmd];
|
||||
} else {
|
||||
ws_received_list_other = [data, ...ws_received_list_other];
|
||||
}
|
||||
|
||||
ws_recv_status = data;
|
||||
};
|
||||
|
||||
conn.onclose = () => {
|
||||
if (log_lvl) console.log('WS V3: connection closed');
|
||||
stop_heartbeat();
|
||||
ws_connect_status = 'disconnected';
|
||||
ws_conn_status = 'disconnected';
|
||||
|
||||
ws_received_list_other = [
|
||||
{
|
||||
msg_type: 'msg',
|
||||
target: 'local',
|
||||
msg: `LOCAL: client ${client} disconnected`
|
||||
},
|
||||
...ws_received_list_other
|
||||
];
|
||||
|
||||
// Auto-reconnect while ws_connect is still true
|
||||
if (ws_connect) {
|
||||
if (ws_retry_count >= 10) {
|
||||
ws_retry_delay = Math.min(ws_retry_delay + 4999, 120_000);
|
||||
console.warn(`WS V3: retry limit reached, delay=${ws_retry_delay}ms`);
|
||||
}
|
||||
setTimeout(() => {
|
||||
ws_retry_count += 1;
|
||||
ws_group = ws_connect_group_id({ group, client });
|
||||
}, ws_retry_delay);
|
||||
}
|
||||
};
|
||||
|
||||
conn.onerror = () => {
|
||||
console.warn('WS V3: connection error');
|
||||
};
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
// *** Send helper
|
||||
|
||||
function handle_send() {
|
||||
if (!ws_group || ws_group.readyState !== WebSocket.OPEN) {
|
||||
console.warn('WS V3: send attempted with no open connection');
|
||||
conn.onmessage = (event) => {
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
console.warn('WS V3: non-JSON message ignored', event.data);
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, any> = {
|
||||
msg_type: 'cmd',
|
||||
target: 'group'
|
||||
};
|
||||
if (cmd) payload.cmd = cmd;
|
||||
if (msg) payload.msg = msg;
|
||||
|
||||
if (log_lvl) console.log('WS V3: sending', payload);
|
||||
ws_group.send(JSON.stringify(payload));
|
||||
if (log_lvl) console.log('WS V3: received', data);
|
||||
|
||||
ws_sent_status = { ...payload, src: client_id, group_id: group_id };
|
||||
cmd = '';
|
||||
msg = '';
|
||||
}
|
||||
// Ignore messages we sent ourselves (server stamps from_id)
|
||||
if (data.from_id && String(data.from_id) === String(client)) {
|
||||
if (log_lvl) console.log('WS V3: self-echo ignored');
|
||||
return;
|
||||
}
|
||||
|
||||
// *** Reactive effects
|
||||
// Ignore heartbeat echoes silently
|
||||
if (data.msg_type === 'heartbeat') return;
|
||||
|
||||
// Connect / disconnect based on ws_connect flag
|
||||
$effect(() => {
|
||||
if (ws_connect && group_id) {
|
||||
ws_group = ws_connect_group_id({ group: group_id, client: client_id });
|
||||
if (data.msg_type === 'cmd') {
|
||||
ws_received_list_cmd = [data, ...ws_received_list_cmd];
|
||||
} else {
|
||||
stop_heartbeat();
|
||||
ws_group?.close();
|
||||
ws_group = null;
|
||||
ws_connect_status = 'disconnected';
|
||||
ws_received_list_other = [data, ...ws_received_list_other];
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger: send command
|
||||
$effect(() => {
|
||||
if (trigger_send && cmd) {
|
||||
trigger_send = null;
|
||||
handle_send();
|
||||
ws_recv_status = data;
|
||||
};
|
||||
|
||||
conn.onclose = () => {
|
||||
if (log_lvl) console.log('WS V3: connection closed');
|
||||
stop_heartbeat();
|
||||
ws_connect_status = 'disconnected';
|
||||
ws_conn_status = 'disconnected';
|
||||
|
||||
ws_received_list_other = [
|
||||
{
|
||||
msg_type: 'msg',
|
||||
target: 'local',
|
||||
msg: `LOCAL: client ${client} disconnected`
|
||||
},
|
||||
...ws_received_list_other
|
||||
];
|
||||
|
||||
// Auto-reconnect while ws_connect is still true
|
||||
if (ws_connect) {
|
||||
if (ws_retry_count >= 10) {
|
||||
ws_retry_delay = Math.min(ws_retry_delay + 4999, 120_000);
|
||||
console.warn(
|
||||
`WS V3: retry limit reached, delay=${ws_retry_delay}ms`
|
||||
);
|
||||
}
|
||||
setTimeout(() => {
|
||||
ws_retry_count += 1;
|
||||
ws_group = ws_connect_group_id({ group, client });
|
||||
}, ws_retry_delay);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Trigger: connect
|
||||
$effect(() => {
|
||||
if (trigger_connect) {
|
||||
trigger_connect = false;
|
||||
if (!ws_connect) ws_connect = true;
|
||||
}
|
||||
});
|
||||
conn.onerror = () => {
|
||||
console.warn('WS V3: connection error');
|
||||
};
|
||||
|
||||
// Trigger: disconnect
|
||||
$effect(() => {
|
||||
if (trigger_disconnect) {
|
||||
trigger_disconnect = false;
|
||||
stop_heartbeat();
|
||||
if (ws_connect) ws_connect = false;
|
||||
ws_group?.close();
|
||||
ws_group = null;
|
||||
ws_connect_status = 'disconnected';
|
||||
}
|
||||
});
|
||||
return conn;
|
||||
}
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
// *** Send helper
|
||||
|
||||
function handle_send() {
|
||||
if (!ws_group || ws_group.readyState !== WebSocket.OPEN) {
|
||||
console.warn('WS V3: send attempted with no open connection');
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, any> = {
|
||||
msg_type: 'cmd',
|
||||
target: 'group'
|
||||
};
|
||||
if (cmd) payload.cmd = cmd;
|
||||
if (msg) payload.msg = msg;
|
||||
|
||||
if (log_lvl) console.log('WS V3: sending', payload);
|
||||
ws_group.send(JSON.stringify(payload));
|
||||
|
||||
ws_sent_status = { ...payload, src: client_id, group_id: group_id };
|
||||
cmd = '';
|
||||
msg = '';
|
||||
}
|
||||
|
||||
// *** Reactive effects
|
||||
|
||||
// Connect / disconnect based on ws_connect flag
|
||||
$effect(() => {
|
||||
if (ws_connect && group_id) {
|
||||
ws_group = ws_connect_group_id({ group: group_id, client: client_id });
|
||||
} else {
|
||||
stop_heartbeat();
|
||||
ws_group?.close();
|
||||
ws_group = null;
|
||||
ws_connect_status = 'disconnected';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger: send command
|
||||
$effect(() => {
|
||||
if (trigger_send && cmd) {
|
||||
trigger_send = null;
|
||||
handle_send();
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger: connect
|
||||
$effect(() => {
|
||||
if (trigger_connect) {
|
||||
trigger_connect = false;
|
||||
if (!ws_connect) ws_connect = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger: disconnect
|
||||
$effect(() => {
|
||||
if (trigger_disconnect) {
|
||||
trigger_disconnect = false;
|
||||
stop_heartbeat();
|
||||
if (ws_connect) ws_connect = false;
|
||||
ws_group?.close();
|
||||
ws_group = null;
|
||||
ws_connect_status = 'disconnected';
|
||||
}
|
||||
});
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Debug panel — only visible when ws_connect=true and hide__ws_element=false -->
|
||||
<section
|
||||
class:hidden={!ws_connect || hide__ws_element}
|
||||
class="ae_element__websocket container p-1 bg-pink-100 text-xs mx-auto pb-16 mt-32 mb-32 relative"
|
||||
>
|
||||
class="ae_element__websocket relative container mx-auto mt-32 mb-32 bg-pink-100 p-1 pb-16 text-xs">
|
||||
<span class="absolute top-0 right-0 flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { hide__ws_form = !hide__ws_form; }}
|
||||
class="btn btn-sm text-xs hover:preset-filled-tertiary-500"
|
||||
onclick={() => {
|
||||
hide__ws_form = !hide__ws_form;
|
||||
}}
|
||||
class="btn btn-sm hover:preset-filled-tertiary-500 text-xs"
|
||||
class:preset-tonal-tertiary={hide__ws_form}
|
||||
class:preset-filled-tertiary-500={!hide__ws_form}
|
||||
>
|
||||
class:preset-filled-tertiary-500={!hide__ws_form}>
|
||||
{hide__ws_form ? 'Show Form' : 'Hide Form?'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { hide__ws_messages = !hide__ws_messages; }}
|
||||
class="btn btn-sm text-xs hover:preset-filled-tertiary-500"
|
||||
onclick={() => {
|
||||
hide__ws_messages = !hide__ws_messages;
|
||||
}}
|
||||
class="btn btn-sm hover:preset-filled-tertiary-500 text-xs"
|
||||
class:preset-tonal-tertiary={hide__ws_messages}
|
||||
class:preset-filled-tertiary-500={!hide__ws_messages}
|
||||
>
|
||||
class:preset-filled-tertiary-500={!hide__ws_messages}>
|
||||
{hide__ws_messages ? 'Show Messages' : 'Hide Messages?'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { hide__ws_commands = !hide__ws_commands; }}
|
||||
class="btn btn-sm text-xs hover:preset-filled-tertiary-500"
|
||||
onclick={() => {
|
||||
hide__ws_commands = !hide__ws_commands;
|
||||
}}
|
||||
class="btn btn-sm hover:preset-filled-tertiary-500 text-xs"
|
||||
class:preset-tonal-tertiary={hide__ws_commands}
|
||||
class:preset-filled-tertiary-500={!hide__ws_commands}
|
||||
>
|
||||
class:preset-filled-tertiary-500={!hide__ws_commands}>
|
||||
{hide__ws_commands ? 'Show Commands' : 'Hide Commands?'}
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<header>
|
||||
<h1 class="h6 text-center">WebSocket V3 — {ws_connect_status ?? 'not connected'}</h1>
|
||||
<p class="text-center opacity-60">Group: {group_id} · Client: {String(client_id).slice(-8)}</p>
|
||||
<h1 class="h6 text-center">
|
||||
WebSocket V3 — {ws_connect_status ?? 'not connected'}
|
||||
</h1>
|
||||
<p class="text-center opacity-60">
|
||||
Group: {group_id} · Client: {String(client_id).slice(-8)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if !hide__ws_form}
|
||||
<form onsubmit={prevent_default(handle_send)} class="flex flex-row gap-1 flex-wrap mt-2">
|
||||
<form
|
||||
onsubmit={prevent_default(handle_send)}
|
||||
class="mt-2 flex flex-row flex-wrap gap-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={group_id}
|
||||
placeholder="Group ID"
|
||||
class="input text-sm w-36"
|
||||
/>
|
||||
class="input w-36 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={cmd}
|
||||
placeholder="Command"
|
||||
class="input text-sm w-36"
|
||||
/>
|
||||
class="input w-36 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={msg}
|
||||
placeholder="Message"
|
||||
class="input text-sm w-36"
|
||||
/>
|
||||
class="input w-36 text-sm" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm preset-tonal-primary"
|
||||
disabled={!ws_connect || ws_connect_status !== 'connected'}
|
||||
>
|
||||
disabled={!ws_connect || ws_connect_status !== 'connected'}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
@@ -376,8 +392,10 @@
|
||||
<ul class="max-h-32 overflow-y-auto">
|
||||
{#each ws_received_list_cmd as item}
|
||||
<li class="border-b border-pink-200 py-0.5 font-mono">
|
||||
<span class="text-red-700 font-bold">{item.cmd}</span>
|
||||
{#if item.from_id}<span class="opacity-50 ml-1">from {item.from_id}</span>{/if}
|
||||
<span class="font-bold text-red-700">{item.cmd}</span>
|
||||
{#if item.from_id}<span class="ml-1 opacity-50"
|
||||
>from {item.from_id}</span
|
||||
>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -392,7 +410,9 @@
|
||||
<li class="border-b border-pink-200 py-0.5 font-mono">
|
||||
<span class="opacity-50">[{item.msg_type}]</span>
|
||||
{item.msg ?? ''}
|
||||
{#if item.from_id}<span class="opacity-50 ml-1">from {item.from_id}</span>{/if}
|
||||
{#if item.from_id}<span class="ml-1 opacity-50"
|
||||
>from {item.from_id}</span
|
||||
>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -3,116 +3,116 @@
|
||||
are never part of the component's own DOM and would be invisible to scoped
|
||||
CSS. */
|
||||
:global {
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Link styles */
|
||||
a {
|
||||
color: var(--purple);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--purple-contrast);
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* List styles */
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5rem;
|
||||
// border: solid thin red;
|
||||
}
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-left: 1.5rem;
|
||||
// border: solid thin red;
|
||||
}
|
||||
/* Link styles */
|
||||
a {
|
||||
color: var(--purple);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||
|
||||
li p {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
&:hover {
|
||||
color: var(--purple-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Heading styles */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
margin-top: 2.5rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* List styles */
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5rem;
|
||||
// border: solid thin red;
|
||||
}
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-left: 1.5rem;
|
||||
// border: solid thin red;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
li p {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
/* Heading styles */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
margin-top: 2.5rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
/* Code and preformatted text styles */
|
||||
code {
|
||||
background-color: var(--purple-light);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--black);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25em 0.3em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--black);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--white);
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Code and preformatted text styles */
|
||||
code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
background-color: var(--purple-light);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--black);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25em 0.3em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--black);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--white);
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--gray-3);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-2);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--gray-3);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-2);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
||||
} /* end :global */
|
||||
|
||||
Reference in New Issue
Block a user