- app.html: comment out 3 Google Fonts <link> tags (Noto Sans, Noto Serif, Roboto Mono) — no theme or component applies these families; all themes use system-ui. Saves 3 blocking network requests on every page load. - app.html: add subtle CSS-only #ae_loader spinner (1.75rem ring, pointer-events:none) that shows during JS download + root load function, before Svelte mounts. - +layout.svelte: add onMount to remove #ae_loader as soon as Svelte bootstraps; the existing is_hydrating frosted-glass overlay takes over from there. - app.css: comment out orphaned Quicksand @font-face — font-family was never applied to any element so browser never fetched it anyway. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
492 lines
17 KiB
Svelte
492 lines
17 KiB
Svelte
<script lang="ts">
|
|
/** @type {import('./$types').LayoutData} */
|
|
|
|
let log_lvl: number = 0;
|
|
|
|
// *** Import Svelte specific
|
|
import { onMount, untrack } from 'svelte';
|
|
import { goto, invalidateAll } from '$app/navigation';
|
|
|
|
import '../app.css';
|
|
|
|
// *** Import other supporting libraries
|
|
import {
|
|
RefreshCw,
|
|
} from '@lucide/svelte';
|
|
|
|
// Highlight JS
|
|
import hljs from 'highlight.js/lib/core';
|
|
import 'highlight.js/styles/github-dark.css';
|
|
|
|
import { browser } from '$app/environment';
|
|
import { online } from 'svelte/reactivity/window';
|
|
import xml from 'highlight.js/lib/languages/xml'; // for HTML
|
|
import css from 'highlight.js/lib/languages/css';
|
|
import javascript from 'highlight.js/lib/languages/javascript';
|
|
import typescript from 'highlight.js/lib/languages/typescript';
|
|
|
|
hljs.registerLanguage('xml', xml); // for HTML
|
|
hljs.registerLanguage('css', css);
|
|
hljs.registerLanguage('javascript', javascript);
|
|
hljs.registerLanguage('typescript', typescript);
|
|
|
|
// *** Import Aether specific variables and functions
|
|
// import Analytics from '$lib/app_components/e_app_analytics.svelte';
|
|
import {
|
|
ae_loc,
|
|
ae_sess,
|
|
ae_api,
|
|
slct,
|
|
slct_trigger,
|
|
ae_auth_error
|
|
} from '$lib/stores/ae_stores';
|
|
import { LoaderCircle } from '@lucide/svelte';
|
|
|
|
import E_app_debug_menu from '$lib/app_components/e_app_debug_menu.svelte';
|
|
import E_app_sys_bar from '$lib/app_components/e_app_sys_bar.svelte';
|
|
import { pwa_install } from '$lib/pwa/pwa_install.svelte';
|
|
|
|
interface Props {
|
|
data: any;
|
|
children?: import('svelte').Snippet;
|
|
}
|
|
|
|
let { data, children }: Props = $props();
|
|
|
|
// Remove the pre-JS loader from app.html as soon as Svelte mounts.
|
|
// The existing is_hydrating overlay takes over from here.
|
|
onMount(() => {
|
|
document.getElementById('ae_loader')?.remove();
|
|
});
|
|
|
|
// STABLE DERIVATION: Using prop data directly to avoid store loops
|
|
let ae_acct = $derived(data[data.account_id]);
|
|
|
|
let flag_clear_idb: boolean = $state(false);
|
|
let flag_clear_local: boolean = $state(false);
|
|
let flag_clear_sess: boolean = $state(false);
|
|
let flag_reload: boolean = $state(false);
|
|
let flag_hard_reload: boolean = $state(false);
|
|
|
|
let flag_new_ver: boolean = $state(false);
|
|
let flag_expired: boolean = $state(false);
|
|
let flag_denied: boolean = $state(false);
|
|
|
|
// Connection Status
|
|
let is_offline = $derived(browser && online.current === false);
|
|
let api_unreachable = $derived($ae_loc?.account_id === 'ghost');
|
|
let api_error_msg = $derived($ae_loc?.account_name || 'API Server Unreachable');
|
|
let show_connection_details = $state(true);
|
|
|
|
let last_reload_time = 0;
|
|
let sw_heal_in_flight = false;
|
|
|
|
// Shallow equality guard — avoids triggering Svelte store updates when the merged
|
|
// object is functionally identical to the current one. Comparing JSON.stringify on
|
|
// large objects like $ae_loc (site config, device info, flags) is expensive and
|
|
// runs on every navigation. Key-by-key identity check is O(n keys), not O(n chars).
|
|
function shallow_equal(
|
|
a: Record<string, any>,
|
|
b: Record<string, any>
|
|
): boolean {
|
|
const keys_a = Object.keys(a);
|
|
const keys_b = Object.keys(b);
|
|
if (keys_a.length !== keys_b.length) return false;
|
|
for (const k of keys_a) {
|
|
if (a[k] !== b[k]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// 1. CONSOLIDATED SYNC EFFECT (One single point of entry for store updates)
|
|
$effect(() => {
|
|
if (!browser || !ae_acct) return;
|
|
|
|
untrack(() => {
|
|
if (log_lvl > 1) console.log('ROOT: Running Sync Effect');
|
|
|
|
// A. Sync Global Stores
|
|
const current_api = $ae_api;
|
|
const new_api = { ...current_api, ...(ae_acct.api || {}) };
|
|
// Deep check for JWT specifically to avoid extra triggers
|
|
if (current_api.jwt !== $ae_loc.jwt) new_api.jwt = $ae_loc.jwt;
|
|
if (!shallow_equal(current_api, new_api)) {
|
|
$ae_api = new_api;
|
|
}
|
|
|
|
const current_loc = $ae_loc;
|
|
const new_loc = { ...current_loc, ...(ae_acct.loc || {}) };
|
|
// Restore structural integrity if clobbered
|
|
if (!new_loc.sys_menu)
|
|
new_loc.sys_menu = {
|
|
hide: false,
|
|
hide_access_type: false,
|
|
expand_access_type: false,
|
|
hide_edit_mode: false,
|
|
expand_edit_mode: false,
|
|
hide_user: false,
|
|
expand_user: false,
|
|
hide_theme: false,
|
|
expand_theme: false,
|
|
hide_cfg: false,
|
|
expand_cfg: false
|
|
};
|
|
if (!new_loc.debug_menu)
|
|
new_loc.debug_menu = { hide: false, expand: false };
|
|
|
|
if (!shallow_equal(current_loc, new_loc)) {
|
|
$ae_loc = new_loc;
|
|
}
|
|
|
|
const current_slct = $slct;
|
|
const new_slct = { ...current_slct, ...(ae_acct.slct || {}) };
|
|
if (!shallow_equal(current_slct, new_slct)) {
|
|
$slct = new_slct;
|
|
}
|
|
|
|
// B. Version & Sanity Check
|
|
if (new_loc.ver && $ae_sess.ver && new_loc.ver !== $ae_sess.ver) {
|
|
if (!flag_new_ver) {
|
|
console.log(
|
|
'ROOT: Version mismatch detected; clearing stale service workers and caches before reload'
|
|
);
|
|
flag_new_ver = true;
|
|
flag_hard_reload = true;
|
|
flag_reload = true;
|
|
}
|
|
}
|
|
|
|
// C. Access Check (Idempotent)
|
|
if (new_loc.site_access_key || new_loc.site_domain_access_key) {
|
|
const key = new_loc.allow_access;
|
|
const match =
|
|
new_loc.site_access_key === key ||
|
|
new_loc.site_domain_access_key === key;
|
|
if (!match && !new_loc.trusted_access) {
|
|
if (new_loc.allow_access !== false)
|
|
$ae_loc.allow_access = false;
|
|
if (!flag_denied) flag_denied = true;
|
|
}
|
|
} else if (new_loc.allow_access !== true) {
|
|
$ae_loc.allow_access = true;
|
|
}
|
|
|
|
// D. Theme Initialization (Once)
|
|
if (!current_loc.theme_mode) {
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
$ae_loc.theme_mode = 'dark';
|
|
else $ae_loc.theme_mode = 'light';
|
|
}
|
|
});
|
|
});
|
|
|
|
// 2. RELOAD THROTTLE EFFECT
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
if (flag_reload) {
|
|
untrack(() => {
|
|
const now = Date.now();
|
|
if (now - last_reload_time < 10000) {
|
|
console.warn(
|
|
'ROOT: Critical loop prevention - reload suppressed'
|
|
);
|
|
flag_reload = false;
|
|
return;
|
|
}
|
|
last_reload_time = now;
|
|
flag_reload = false;
|
|
|
|
if (flag_clear_local) clear_local();
|
|
if (flag_clear_sess) clear_sess();
|
|
|
|
if (flag_hard_reload) {
|
|
flag_hard_reload = false;
|
|
console.log('ROOT: Executing hard reload after service worker heal');
|
|
void clear_stale_service_worker_state().finally(() => {
|
|
location.reload();
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log('ROOT: Executing throttled reload');
|
|
invalidateAll();
|
|
});
|
|
}
|
|
});
|
|
|
|
// 3. UI STATE & THEME DOM EFFECT
|
|
let is_hydrating = $state(true);
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
// Theme name — controls which Skeleton color palette is active
|
|
document.documentElement.setAttribute(
|
|
'data-theme',
|
|
$ae_loc?.theme_name ?? 'nouveau'
|
|
);
|
|
|
|
// Dark/light class — controls Tailwind v4 class-based dark mode variant.
|
|
// @custom-variant dark in app.css registers this; without it the class does nothing.
|
|
if ($ae_loc?.theme_mode === 'dark') {
|
|
document.documentElement.classList.add('dark');
|
|
document.documentElement.classList.remove('light');
|
|
} else {
|
|
document.documentElement.classList.add('light');
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
|
|
// Font size mode — cycles default | larger | smaller.
|
|
// CSS classes are defined in app.css; no class = browser default (16px).
|
|
document.documentElement.classList.remove(
|
|
'font-size-larger',
|
|
'font-size-smaller'
|
|
);
|
|
if ($ae_loc?.font_size_mode === 'larger') {
|
|
document.documentElement.classList.add('font-size-larger');
|
|
} else if ($ae_loc?.font_size_mode === 'smaller') {
|
|
document.documentElement.classList.add('font-size-smaller');
|
|
}
|
|
|
|
// Hydration overlay timer
|
|
if ($ae_loc?.account_id) {
|
|
const timer = setTimeout(() => (is_hydrating = false), 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
});
|
|
|
|
function clear_idb() {
|
|
indexedDB.deleteDatabase('ae_archives_db');
|
|
indexedDB.deleteDatabase('ae_core_db');
|
|
indexedDB.deleteDatabase('ae_events_db');
|
|
indexedDB.deleteDatabase('ae_journals_db');
|
|
indexedDB.deleteDatabase('ae_posts_db');
|
|
indexedDB.deleteDatabase('ae_sponsorships_db');
|
|
}
|
|
|
|
function clear_local() {
|
|
localStorage.clear();
|
|
}
|
|
|
|
function clear_sess() {
|
|
sessionStorage.clear();
|
|
}
|
|
|
|
async function clear_stale_service_worker_state() {
|
|
if (!browser || sw_heal_in_flight) return;
|
|
sw_heal_in_flight = true;
|
|
|
|
try {
|
|
if ('serviceWorker' in navigator) {
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
for (const registration of registrations) {
|
|
await registration.unregister();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('ROOT: Service worker unregister failed during heal:', err);
|
|
}
|
|
|
|
try {
|
|
if ('caches' in window) {
|
|
const cache_keys = await caches.keys();
|
|
for (const cache_key of cache_keys) {
|
|
await caches.delete(cache_key);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('ROOT: Cache cleanup failed during heal:', err);
|
|
} finally {
|
|
sw_heal_in_flight = false;
|
|
}
|
|
}
|
|
|
|
// 4. EXTERNAL INTERFACES EFFECT
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
// Initialise PWA install prompt listener (captures beforeinstallprompt early).
|
|
pwa_install.init();
|
|
|
|
// Save DS to local
|
|
let ae_ds = ae_acct?.ds;
|
|
if (ae_ds) {
|
|
for (let [key, value] of Object.entries(ae_ds)) {
|
|
localStorage.setItem(`ae_ds__${key}`, JSON.stringify(value));
|
|
}
|
|
}
|
|
|
|
// Message Bridge
|
|
const handler = (event: MessageEvent) => {
|
|
if (event.data.type === 'api_download_blob') {
|
|
$ae_sess.api_download_kv[event.data.task_id] = { ...event.data };
|
|
} else if (event.data.type === 'api_post_json_form') {
|
|
$ae_sess.api_upload_kv[event.data.task_id] = { ...event.data };
|
|
}
|
|
};
|
|
window.addEventListener('message', handler);
|
|
|
|
// Iframe Detection
|
|
let iframe = data.url.searchParams.get('iframe');
|
|
// let iframe = $derived(data.url.searchParams.get('iframe'));
|
|
|
|
if (iframe === 'true') {
|
|
$ae_loc.iframe = true;
|
|
// Hide the AE system bar by default in iframe embeds — it's nav chrome
|
|
// the host page doesn't need. Trusted admins can override with show_menu=true
|
|
// to access the menu while testing an embedded page (e.g. video_conferences).
|
|
$ae_loc.sys_menu.hide = true;
|
|
} else if (iframe === 'false') {
|
|
$ae_loc.iframe = false;
|
|
}
|
|
|
|
// show_menu=true — override to show the AE system bar even inside an iframe.
|
|
// Intended for trusted/admin users who need menu access while testing an embed.
|
|
// hide_menu=true — explicitly hide the bar outside of iframe contexts.
|
|
// const menu_override = data.url.searchParams.get('show_menu');
|
|
// if (menu_override === 'true') $ae_loc.sys_menu.hide = false;
|
|
// else if (data.url.searchParams.get('hide_menu') === 'true')
|
|
// $ae_loc.sys_menu.hide = true;
|
|
|
|
const menu_override = data.url.searchParams.get('show_menu');
|
|
if (menu_override === 'true') {
|
|
$ae_loc.sys_menu.hide = false;
|
|
} else if (data.url.searchParams.get('hide_menu') === 'true') {
|
|
$ae_loc.sys_menu.hide = true;
|
|
} else if (data.url.searchParams.get('iframe') === 'true') {
|
|
$ae_loc.sys_menu.hide = true;
|
|
} else if (data.url.searchParams.get('iframe') === 'false') {
|
|
$ae_loc.sys_menu.hide = false;
|
|
}
|
|
// console.log('iframe:', $ae_loc.iframe, 'sys_menu.hide:', $ae_loc.sys_menu.hide);
|
|
|
|
// Theme URL params — ?theme=AE_Firefly_SteelBlue&theme_mode=dark
|
|
// Applied once on load, then silently removed from the URL (no history entry).
|
|
const url_theme = data.url.searchParams.get('theme');
|
|
const url_theme_mode = data.url.searchParams.get('theme_mode');
|
|
if (url_theme || url_theme_mode) {
|
|
if (url_theme) {
|
|
// Mark that the user (or URL) explicitly chose a theme so site defaults
|
|
// won't overwrite it later on subsequent loads.
|
|
ae_loc.update((l: any) => ({ ...l, theme_name: url_theme, user_theme_selected: true }));
|
|
}
|
|
if (url_theme_mode === 'light' || url_theme_mode === 'dark') {
|
|
ae_loc.update((l: any) => ({ ...l, theme_mode: url_theme_mode }));
|
|
}
|
|
const clean_url = new URL(data.url.href);
|
|
clean_url.searchParams.delete('theme');
|
|
clean_url.searchParams.delete('theme_mode');
|
|
goto(clean_url.pathname + clean_url.search + clean_url.hash, {
|
|
replaceState: true,
|
|
noScroll: true,
|
|
keepFocus: true
|
|
});
|
|
}
|
|
|
|
// Electron Detection
|
|
if ((window as any).native_app || (window as any).aetherNative) {
|
|
if (!$ae_loc.is_native) $ae_loc.is_native = true;
|
|
}
|
|
|
|
return () => window.removeEventListener('message', handler);
|
|
});
|
|
|
|
// 5. SESSION EXPIRED EFFECT — watches ae_auth_error and raises the banner when the API signals 401/403.
|
|
// Guards on $ae_loc.jwt so that unauthenticated first-loads (no stored JWT) never trigger it —
|
|
// 401s are expected on first visit and should not look like a session expiry.
|
|
$effect(() => {
|
|
if ($ae_auth_error?.type === 'expired') {
|
|
untrack(() => {
|
|
if ($ae_loc?.jwt) flag_expired = true;
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<link rel="stylesheet" href={ae_acct?.loc?.site_style_href} />
|
|
</svelte:head>
|
|
|
|
{#if browser && (is_offline || api_unreachable)}
|
|
<div
|
|
class="preset-filled-error-200-800 fixed top-0 right-0 left-0 z-100 flex items-center justify-center gap-4 p-4 text-center shadow-2xl print:hidden">
|
|
<span class="text-xl font-bold"
|
|
>{is_offline ? 'Offline' : api_error_msg}</span>
|
|
<button
|
|
class="btn btn-sm preset-tonal-surface"
|
|
onclick={() => window.location.reload()}>
|
|
<RefreshCw size="1em" class="opacity-60" /> Retry
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if browser && flag_expired}
|
|
<div
|
|
class="preset-filled-warning-500 fixed top-0 right-0 left-0 z-50 flex items-center justify-between gap-4 px-4 py-2 shadow-xl print:hidden">
|
|
<p class="text-sm font-semibold">
|
|
Your session has expired. Please reload or sign in again.
|
|
</p>
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
class="btn btn-sm preset-tonal-surface"
|
|
onclick={() => window.location.reload()}>
|
|
<RefreshCw size="1em" class="opacity-60" /> Reload
|
|
</button>
|
|
<button
|
|
class="btn btn-sm preset-outlined-surface"
|
|
onclick={() => {
|
|
flag_expired = false;
|
|
ae_auth_error.set({ type: null, ts: null });
|
|
}}>✕</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if browser && $ae_loc?.allow_access}
|
|
{@render children?.()}
|
|
|
|
{#if is_hydrating}
|
|
<div
|
|
class="bg-surface-50/80 dark:bg-surface-900/80 fixed inset-0 z-99 flex flex-col items-center justify-center backdrop-blur-sm transition-opacity duration-500 print:hidden">
|
|
<div
|
|
class="preset-filled-surface-100-900 flex flex-col items-center gap-4 rounded-2xl p-8 shadow-2xl">
|
|
<LoaderCircle
|
|
size="3rem"
|
|
class="text-primary-500 animate-spin" />
|
|
<div class="text-center text-xl font-bold">
|
|
<!-- Hydrating Aether... -->
|
|
Loading Aether data...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{:else if browser && flag_denied}
|
|
<div class="flex h-screen flex-col items-center justify-center gap-6 p-8">
|
|
<h1 class="text-error-500 text-4xl font-black">Access Denied</h1>
|
|
<button
|
|
class="btn preset-filled-primary"
|
|
onclick={() => window.location.reload()}>Reload</button>
|
|
</div>
|
|
{:else if browser}
|
|
<div class="flex h-screen items-center justify-center">
|
|
<LoaderCircle size="2.5rem" class="animate-spin opacity-20" />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if browser && (!$ae_loc?.iframe || !$ae_loc?.sys_menu?.hide || $ae_loc?.administrator_access || ($ae_loc?.trusted_access && $ae_loc.edit_mode))}
|
|
<!-- print:hidden wrapper: sys/debug menus are fixed overlays — must not appear on printed pages -->
|
|
<div class="print:hidden">
|
|
<E_app_sys_bar
|
|
{data}
|
|
bind:hide={$ae_loc.sys_menu.hide}
|
|
bind:expand={$ae_sess.sys_menu.expand} />
|
|
|
|
<!-- You must be in Edit Mode to initially see the Debug expand button. Once expanded, you can toggle the Edit Mode while still seeing the expanded Debug content. -->
|
|
{#if $ae_loc.edit_mode || $ae_loc.debug_menu.expand}
|
|
<E_app_debug_menu
|
|
bind:hide={$ae_loc.debug_menu.hide}
|
|
bind:expand={$ae_loc.debug_menu.expand} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|