10 Commits

Author SHA1 Message Date
Scott Idem
07d21c29c8 feat: add trusted-only debug outlines toggle to badge print controls panel 2026-03-18 13:05:17 -04:00
Scott Idem
ec5b09dfaa feat: hide AE menu by default in iframe mode; add show_menu override
iframe=true now hides the sys bar for all users (previously trusted_access
users still saw it). Admins can pass show_menu=true to re-enable it while
testing an embedded page like video_conferences.

hide_menu=true remains for non-iframe hide use cases (kiosk, etc).

Updated URL builder: hide_menu checkbox → show_menu checkbox.
Updated GUIDE__Development.md URL params table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:18:09 -04:00
Scott Idem
c1f96ba94e docs: update GUIDE__Development.md — add URL params, refresh stale content
- Add Section 6: full URL parameters reference (global + per-module)
- Update coordination section: message tool → ae_send_message MCP
- Expand Section 5 into a proper key documentation table
- Fix section numbering (Continuity was unnumbered)
- Bump version to 1.2 (2026-03-17)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:09:05 -04:00
Scott Idem
931df5581f refactor: rename ae_hide_menu URL param to hide_menu
ae_ prefix belongs on Svelte component/variable names, not URL params.
Updated both the consumer (+layout.svelte) and the builder (jitsi_url_builder).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:03:28 -04:00
Scott Idem
5978c5e341 fix: read ae_hide_menu URL param and apply to sys_menu.hide in layout
The URL builder generates ae_hide_menu=true but nothing consumed it.
Now layout.svelte reads the param on mount and sets $ae_loc.sys_menu.hide,
which flows through bind:hide into E_app_sys_bar's class:hidden.

Applies even for trusted_access users who bypass the iframe guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:55:36 -04:00
Scott Idem
93bd8ba962 feat: add hide_ae_menu toggle to Jitsi URL builder advanced panel
Adds ae_hide_menu=true query param option to suppress the AE navigation
chrome when embedding the Jitsi video conference page in Novi or other
host pages that provide their own navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:51:16 -04:00
Scott Idem
543dc3c300 feat(jitsi): add 'Hide AE system menu' embed toggle and URL param 2026-03-17 19:31:34 -04:00
Scott Idem
dcfeb99024 feat(idaa): add Jitsi URL Builder tool to reports page
New component ae_idaa_comp__jitsi_url_builder.svelte builds and previews
Jitsi iframe URLs for testing and Novi page configuration. Features:
- Environment selector (prod / dev / local / custom)
- Room name, Novi UUID, site key inputs
- Moderator toggle (explains JWT + logging implication)
- Advanced: domain, start muted/hidden, all 5 sound settings
- Output in URL or iframe HTML snippet mode with copy button
- "Open in new tab" for quick testing

Embedded on jitsi_reports page as a collapsible panel, gated to
trusted_access users only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:24:38 -04:00
Scott Idem
8693989a69 security: move jitsi_reports inside (idaa) auth gate
jitsi_reports was previously at src/routes/idaa/jitsi_reports/ and
was not protected by the (idaa) layout auth gate. Moved to
src/routes/idaa/(idaa)/jitsi_reports/ — same URL, now requires
trusted_access or Novi-verified authenticated access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:15:47 -04:00
Scott Idem
9fc3ee0198 fix(imports): point to element_data_store_v3 and restore Data Store v3; commit workspace updates 2026-03-17 18:57:27 -04:00
28 changed files with 1461 additions and 166 deletions

View File

@@ -1,39 +1,113 @@
# Aether Development SOP (Frontend)
> **Version:** 1.1 (2026-02-16)
> **Version:** 1.2 (2026-03-17)
> **Location:** documentation/GUIDE__Development.md
## 1. 🛡️ Verification (The "Test-First" Mandate)
**Rule:** No code is to be committed unless it has passed local verification. Skipping this is a violation of the Aether Dev Protocol.
## 1. Verification (The "Test-First" Mandate)
**Rule:** No code is to be committed unless it has passed local verification.
### Required Checks
1. **Svelte Integrity:** `npx svelte-check`
- **Zero Tolerance Policy:** If a task introduces even a single svelte-check warning or error, it must not be merged. All warnings must be resolved before code review or merge. This prevents the "circle-running" and technical debt that results from ignoring warnings.
- **Zero Tolerance:** If a task introduces even a single svelte-check warning or error, it must not be merged. Resolve all warnings before committing.
2. **Type Safety:** Ensure interfaces in `src/lib/types/ae_types.ts` match backend schemas.
3. **Reactivity Check:** Verify Svelte 5 runes (`$state`, `$derived`) are not creating race conditions with Dexie `liveQuery`.
4. **Build Check:** For major changes, run `npm run build:staging` to ensure no SSR or build-time failures.
## 2. 📝 Commit & Sync Policy
## 2. Commit Policy
- **Atomic Commits:** One component or one logic fix per commit. Do not batch unrelated changes.
- **Verification Log:** Mention the verification steps taken in your work log (`ae_log_work`).
- **Safety:** Use `~/tmp/gemini_trash` for removals; never use `rm` directly on source files.
- **Safety:** Use `~/tmp/gemini_trash` for file removal; never use `rm` directly on source files.
- **Secrets:** Never commit `.env`, API keys, or passwords.
## 3. 🤝 The Handshake (Coordination)
You are not working in a vacuum. You MUST coordinate with the Backend Agent.
## 3. Coordination (The Handshake)
You are not working in a vacuum. Coordinate with the Backend Agent via MCP tools.
### Mandatory Messaging Triggers
- **Data Requirements:** When a UI feature requires a new field or endpoint.
- **API Failures:** When a V3 endpoint returns unexpected data or 500s.
- **Status:** IGNORE THIS FOR NOW: ~~Update your shared Journal in `~/agents_sync/aether/journals/` after significant milestones.~~
- **Stuck in Loop or Insufficient Information: ** If you find yourself in a loop or lacking information, ask Scott and or use the `message` tool to ask for clarification or assistance from the Backend Agent.
- **API Failures:** When a V3 endpoint returns unexpected data or errors.
- **Blocked:** If stuck in a loop or lacking information, use `ae_send_message` to ask the Backend Agent, or flag for Scott.
**Tool:** Use the `message` tool to communicate with the Backend Agent or Scott's other agents.
### Tools
- `ae_send_message` / `ae_inbox` — agent-to-agent messaging
- `ae_task_list` / `ae_task_add` / `ae_task_complete` — shared Kanban board
- `ae_log_work` — log activity to daily journal
## 🧠 Continuity
Before starting work:
1. Read `~/agents_sync/README.md` to understand the fleet status and cross-agent tasks.
2. Check `README.md` in the project root for technical specs.
3. Review your local `documentation/TODO__Agents.md` for active tasks.
4. Be sure to describe the plan before you start making code changes to one or more files.
## 4. Continuity (Before Starting Work)
1. Review `documentation/TODO__Agents.md` for active tasks.
2. Check `~/agents_sync/README.md` for fleet status and cross-agent tasks.
3. Describe your plan before making code changes across multiple files.
## 4. Aether UI/UX and API V3 Documentation
* documentation/GUIDE__AE_API_V3_for_Frontend.md
## 5. Key Documentation
| File | Purpose |
| --- | --- |
| `documentation/TODO__Agents.md` | Active task list — read first |
| `documentation/GUIDE__AE_API_V3_for_Frontend.md` | V3 API reference (authoritative) |
| `documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md` | Dexie + liveQuery patterns |
| `documentation/GEMINI__Svelte_and_Me.md` | Svelte 5 runes patterns |
| `documentation/AE__Architecture.md` | System architecture overview |
| `documentation/AE__Naming_Conventions.md` | Naming rules |
| `documentation/PROJECT__AE_Events_Launcher_Native_integration.md` | Electron/Launcher reference |
## 6. URL Parameters
URL params consumed by the app. Params are read by layouts and applied on mount.
### Global (active on all routes — read by `src/routes/+layout.svelte`)
| Param | Values | Effect |
| --- | --- | --- |
| `iframe` | `true` / `false` | Enables iframe mode — hides the AE system bar for all users by default, suppresses sign-in/passcode UI |
| `show_menu` | `true` | Override: show the AE system bar inside an iframe. Intended for admins/trusted users who need menu access while testing an embed. |
| `hide_menu` | `true` | Explicitly hide the AE system bar outside of iframe mode (e.g. fullscreen kiosk pages). |
| `theme` | theme name | Applies a theme on load, then removes param from URL (no history entry) |
| `theme_mode` | `light` / `dark` | Applies theme mode on load, then removes param from URL |
### IDAA Module (`/idaa/` routes)
| Param | Values | Consumed by | Effect |
| --- | --- | --- | --- |
| `iframe` | `true` | `idaa/+layout.svelte` | Hides IDAA nav chrome |
| `uuid` | Novi UUID | `idaa/(idaa)/+layout.svelte` | Sets Novi UUID → triggers member auth lookup |
### IDAA Video Conferences (`/idaa/video_conferences`)
| Param | Values | Effect |
| --- | --- | --- |
| `uuid` | Novi UUID | Member identity for Jitsi JWT |
| `key` | site key string | Site auth key |
| `room` | room name | Jitsi room name |
| `moderator` | `true` | Grants moderator role + JWT, enables lobby and activity logging |
| `domain` | hostname | Jitsi server (default: `jitsi.dgrzone.com`) |
| `start_muted` | `true` | Start audio muted |
| `start_hidden` | `true` | Start video off |
| `incoming_msg_sound` | `true` | Disable incoming message sound |
| `participant_joined_sound` | `true` | Disable participant joined sound |
| `participant_left_sound` | `true` | Disable participant left sound |
| `reaction_sound` | `true` | Disable reaction sound |
| `raise_hand_sound` | `true` | Disable raise hand sound |
### Events Launcher (`/events/[id]/launcher`)
| Param | Values | Effect |
| --- | --- | --- |
| `session_id` | session ID | Pre-selects a session on load |
| `iframe` | `true` | Iframe mode flag |
| `launcher_menu` | show/hide | Show/hide launcher menu chrome |
| `launcher_header` | show/hide | Show/hide launcher header |
| `launcher_footer` | show/hide | Show/hide launcher footer |
### Events Sign-In (`/events/[id]/sign_in_out`)
| Param | Values | Effect |
| --- | --- | --- |
| `person_id` | person ID | Pre-fill attendee |
| `person_pass` | passphrase | Auto-authenticate attendee |
| `presentation_id` | ID | Pre-select presentation |
| `presenter_id` | ID | Pre-select presenter |
| `session_id` | ID | Pre-select session |
### Badges (`/events/[id]/badges/print_list`)
| Param | Values | Effect |
| --- | --- | --- |
| `printed_status` | filter value | Filter badge list by print status |
| `badge_type_code` | code string | Filter badge list by type |

View File

@@ -38,7 +38,6 @@ export async function qry__jitsi_report({
// Step 1: Query all relevant activity logs from the API.
const search_query = {
or: [
{ field: 'name', op: 'eq', value: 'jitsi_meeting_stats_update' },
{ field: 'name', op: 'eq', value: 'jitsi_meeting_event' },
{ field: 'name', op: 'eq', value: 'jitsi_meeting_stats' }
],

View File

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

View File

@@ -251,8 +251,22 @@
// Iframe Detection
let iframe = data.url.searchParams.get('iframe');
if (iframe === 'true') $ae_loc.iframe = true;
else if (iframe === 'false') $ae_loc.iframe = false;
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;
// Theme URL params — ?theme=AE_Firefly_SteelBlue&theme_mode=dark
// Applied once on load, then silently removed from the URL (no history entry).

View File

@@ -8,7 +8,7 @@
// import { PUBLIC_TESTING } from '$env/static/public';
// console.log(`AE Config - +page.svelte PUBLIC_TESTING:`, PUBLIC_TESTING);
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
// import { api } from '$lib/api';
import { ae_loc, ae_sess, ae_api, slct, slct_trigger } from '$lib/stores/ae_stores';

View File

@@ -8,7 +8,7 @@
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/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import { liveQuery } from 'dexie';
// import { core_func } from '$lib/ae_core_functions';

View File

@@ -22,7 +22,7 @@
// *** Import Aether specific variables and functions
import type { key_val } from '$lib/stores/ae_stores';
import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import {
events_loc,
events_sess,

View File

@@ -283,6 +283,22 @@
?? ''
);
// --- Debug Outlines Toggle ---
let debug_outlines = $state(false);
$effect(() => {
if (browser) {
if (debug_outlines) {
document.documentElement.classList.add('debug_outlines');
} else {
document.documentElement.classList.remove('debug_outlines');
}
}
return () => {
if (browser) document.documentElement.classList.remove('debug_outlines');
};
});
// --- Focus management: focus the input when its accordion opens ---
// rAF gives the CSS accordion one repaint tick before focus() is called,
// avoiding jumping to an invisible (height: 0) element.
@@ -863,6 +879,18 @@
<span class="flex-1 border-t border-gray-200 dark:border-gray-700"></span>
</div>
<!-- Debug Outlines Toggle (for print testing) -->
<div class="flex items-center gap-2 px-2 py-1.5">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
class="checkbox"
bind:checked={debug_outlines}
/>
<span class="text-xs">Show debug outlines</span>
</label>
</div>
<!-- === BADGE TYPE === (only when template defines badge_type_list) -->
{#if badge_type_code_li.length > 0}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'badge_type'}>

View File

@@ -20,7 +20,7 @@
import { events_func } from '$lib/ae_events_functions';
import { api } from '$lib/api/api';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Comp__events_menu_nav from '../../ae_comp__events_menu_nav.svelte';
import Comp__pres_mgmt_menu_opts from '../../ae_comp__events_menu_opts.svelte';
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';

View File

@@ -25,7 +25,7 @@
import { events_func } from '$lib/ae_events_functions';
import { api } from '$lib/api/api';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';

View File

@@ -22,7 +22,7 @@
import type { key_val } from '$lib/stores/ae_stores';
// import { ae_util } from '$lib/ae_utils/ae_utils';
import Element_ae_obj_field_editor_v3 from '$lib/elements/element_ae_obj_field_editor_v3.svelte';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
let ae_promises: key_val = $state({});
// let ae_tmp: key_val = {};

View File

@@ -14,7 +14,7 @@
import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
let ae_promises: key_val = {};
let ae_tmp: key_val = {};

View File

@@ -14,7 +14,7 @@
events_slct
} from '$lib/stores/ae_events_stores';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Comp__events_menu_nav from '../../../ae_comp__events_menu_nav.svelte';
let show_modal = $state(false);

View File

@@ -11,7 +11,7 @@
// Imports (external and then internal)
// import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import { liveQuery } from 'dexie';
import { Modal } from 'flowbite-svelte';

View File

@@ -17,7 +17,7 @@
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 Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import {
ae_loc,

View File

@@ -25,7 +25,7 @@
import { events_func } from '$lib/ae_events_functions';
import { api } from '$lib/api/api';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';

View File

@@ -14,7 +14,7 @@
events_slct
} from '$lib/stores/ae_events_stores';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Comp__events_menu_nav from '../../../ae_comp__events_menu_nav.svelte';
let show_modal = $state(false);

View File

@@ -29,7 +29,7 @@
import { events_func } from '$lib/ae_events_functions';
// Import components and elements
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import { Check, CheckCircle, LoaderCircle, TriangleAlert, X } from '@lucide/svelte';
// Local Variables
let ae_promises: key_val = $state({});

View File

@@ -27,7 +27,7 @@
import { events_func } from '$lib/ae_events_functions';
import { api } from '$lib/api/api';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Sign_in_out from '../../../sign_in_out.svelte';
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';

View File

@@ -0,0 +1,460 @@
<script lang="ts">
import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_loc } from '$lib/stores/ae_stores';
import JitsiUrlBuilder from './ae_idaa_comp__jitsi_url_builder.svelte';
interface MeetingEvent {
timestamp: string;
action: string;
details: { full_name?: string };
}
interface MeetingParticipant {
displayName: string;
role: string;
}
interface MeetingReport {
meeting_id: string;
room_name: string;
start_time: string;
final_duration: string;
final_participant_count: number;
final_participants: MeetingParticipant[];
events: MeetingEvent[];
}
interface Props {
data: { streamed: { meetings: Promise<MeetingReport[]> } };
}
let { data }: Props = $props();
// --- Data state ---
// Resolve the streamed promise into reactive state so we can filter and export it.
let meetings_all = $state<MeetingReport[]>([]);
let meetings_loading = $state(true);
let meetings_error = $state<string | null>(null);
$effect(() => {
meetings_loading = true;
meetings_error = null;
data.streamed.meetings
.then((m: MeetingReport[]) => {
meetings_all = m ?? [];
meetings_loading = false;
})
.catch((err: Error) => {
meetings_error = err.message;
meetings_loading = false;
});
});
// --- Filter state ---
// Default 0 so historical logs (which lack an init record and have count=0) are visible.
// Once logging is fully established, this can be raised to 1 to hide empty/test meetings.
let filter_min_participants = $state(0);
let filter_room_name = $state('');
let filter_date_from = $state('');
let filter_date_to = $state('');
let filters_are_modified = $derived(
filter_min_participants !== 0 ||
filter_room_name !== '' ||
filter_date_from !== '' ||
filter_date_to !== ''
);
function reset_filters() {
filter_min_participants = 0;
filter_room_name = '';
filter_date_from = '';
filter_date_to = '';
}
// --- Derived: filtered meetings ---
let meetings_filtered = $derived.by(() => {
return meetings_all.filter((m) => {
if ((m.final_participant_count ?? 0) < filter_min_participants) return false;
if (filter_room_name && !m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase())) return false;
if (filter_date_from) {
if (Date.parse(m.start_time) < Date.parse(filter_date_from)) return false;
}
if (filter_date_to) {
// Include full end-of-day by appending T23:59:59 to the date string
if (Date.parse(m.start_time) > Date.parse(filter_date_to + 'T23:59:59.999')) return false;
}
return true;
});
});
// --- Summary stats ---
function parse_duration_seconds(d: string): number {
if (!d) return 0;
const parts = d.split(':').map(Number);
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
}
function format_seconds(total: number): string {
const h = Math.floor(total / 3600).toString().padStart(2, '0');
const m = Math.floor((total % 3600) / 60).toString().padStart(2, '0');
const s = Math.floor(total % 60).toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}
let summary = $derived.by(() => {
const count = meetings_filtered.length;
const total_participants = meetings_filtered.reduce((sum, m) => sum + (m.final_participant_count ?? 0), 0);
const total_secs = meetings_filtered.reduce((sum, m) => sum + parse_duration_seconds(m.final_duration), 0);
const avg_secs = count > 0 ? Math.round(total_secs / count) : 0;
return {
count,
total_participants,
avg_duration: format_seconds(avg_secs),
total_duration: format_seconds(total_secs)
};
});
// --- Accordion state ---
let open_accordions = $state<{ [key: string]: boolean }>({});
let show_url_builder = $state(false);
function toggle_accordion(meeting_id: string) {
open_accordions[meeting_id] = !open_accordions[meeting_id];
}
// --- Export ---
function download_file(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function export_csv() {
const rows: string[][] = [
['Meeting ID', 'Room Name', 'Start Time', 'Duration', 'Participant Count']
];
for (const m of meetings_filtered) {
rows.push([
m.meeting_id ?? '',
m.room_name ?? '',
m.start_time ? new Date(m.start_time).toISOString() : '',
m.final_duration ?? '',
String(m.final_participant_count ?? 0)
]);
}
const csv = rows
.map((r) => r.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
download_file(csv, 'jitsi_meeting_report.csv', 'text/csv;charset=utf-8;');
}
function export_json() {
download_file(
JSON.stringify(meetings_filtered, null, 2),
'jitsi_meeting_report.json',
'application/json'
);
}
</script>
<svelte:head>
<title>&AElig;: Jitsi Meeting Reports</title>
</svelte:head>
<div class="p-4 space-y-4 w-full max-w-5xl">
<!-- Page header + export buttons -->
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<h1 class="text-xl font-bold">Jitsi Meeting Reports</h1>
<div class="flex gap-2">
<button
type="button"
onclick={export_csv}
disabled={meetings_filtered.length === 0}
title="Export filtered meetings as CSV"
class="btn btn-sm preset-tonal-primary disabled:opacity-40"
>
<span class="fas fa-file-csv" aria-hidden="true"></span>
Export CSV
</button>
<button
type="button"
onclick={export_json}
disabled={meetings_filtered.length === 0}
title="Export filtered meetings as JSON"
class="btn btn-sm preset-tonal-surface border border-surface-200-800 disabled:opacity-40"
>
<span class="fas fa-file-code" aria-hidden="true"></span>
Export JSON
</button>
</div>
</div>
<!-- Jitsi URL Builder — trusted_access only -->
{#if $ae_loc.trusted_access}
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl overflow-hidden">
<button
type="button"
onclick={() => (show_url_builder = !show_url_builder)}
class="w-full flex items-center justify-between gap-2 p-3 hover:bg-surface-200-800 transition-colors duration-200 text-left"
>
<span class="flex items-center gap-2 font-semibold text-sm">
<span class="fas fa-tools" aria-hidden="true"></span>
Jitsi URL Builder
</span>
<span
class="fas text-xs opacity-60"
class:fa-chevron-down={!show_url_builder}
class:fa-chevron-up={show_url_builder}
aria-hidden="true"
></span>
</button>
{#if show_url_builder}
<div class="border-t border-surface-200-800 p-4">
<JitsiUrlBuilder />
</div>
{/if}
</div>
{/if}
<!-- Filter bar -->
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 flex flex-row flex-wrap gap-3 items-end">
<div>
<label for="filter_min_p" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
Min. Participants
</label>
<input
type="number"
id="filter_min_p"
min="0"
bind:value={filter_min_participants}
class="border border-surface-200-800 rounded px-2 py-1 w-20 bg-surface-50-950"
/>
</div>
<div>
<label for="filter_room" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
Room Name
</label>
<input
type="text"
id="filter_room"
placeholder="Search rooms..."
bind:value={filter_room_name}
class="border border-surface-200-800 rounded px-2 py-1 bg-surface-50-950"
/>
</div>
<div>
<label for="filter_date_from" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
From
</label>
<input
type="date"
id="filter_date_from"
bind:value={filter_date_from}
class="border border-surface-200-800 rounded px-2 py-1 bg-surface-50-950"
/>
</div>
<div>
<label for="filter_date_to" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
To
</label>
<input
type="date"
id="filter_date_to"
bind:value={filter_date_to}
class="border border-surface-200-800 rounded px-2 py-1 bg-surface-50-950"
/>
</div>
{#if filters_are_modified}
<button
type="button"
onclick={reset_filters}
class="btn btn-sm preset-tonal-surface border border-surface-200-800 self-end"
title="Reset all filters to defaults"
>
<span class="fas fa-times" aria-hidden="true"></span>
Reset
</button>
{/if}
</div>
{#if meetings_loading}
<!-- Loading skeleton -->
<div class="space-y-2 animate-pulse" role="status" aria-live="polite" aria-label="Loading meeting reports">
{#each [1, 2, 3, 4] as _, i (i)}
<div class="h-14 w-full bg-surface-200-800 rounded-xl"></div>
{/each}
</div>
{:else if meetings_error}
<!-- Error state -->
<div class="bg-error-100 border border-error-300 rounded-xl p-4">
<div class="font-bold">Error Loading Reports</div>
<p class="mt-1">An error occurred while fetching the meeting reports:</p>
<pre class="mt-2 text-xs overflow-auto whitespace-pre-wrap">{meetings_error}</pre>
</div>
{:else}
<!-- Summary stats -->
{#if meetings_all.length > 0}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
<div class="text-2xl font-bold">{summary.count}</div>
<div class="text-xs uppercase tracking-wide opacity-40">Meetings Shown</div>
</div>
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
<div class="text-2xl font-bold">{summary.total_participants}</div>
<div class="text-xs uppercase tracking-wide opacity-40">Total Participants</div>
</div>
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
<div class="text-2xl font-bold font-mono text-lg">{summary.avg_duration}</div>
<div class="text-xs uppercase tracking-wide opacity-40">Avg Duration</div>
</div>
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
<div class="text-2xl font-bold font-mono text-lg">{summary.total_duration}</div>
<div class="text-xs uppercase tracking-wide opacity-40">Total Duration</div>
</div>
</div>
{/if}
<!-- Meeting list -->
{#if meetings_filtered.length > 0}
<div class="space-y-2">
{#each meetings_filtered as meeting (meeting.meeting_id)}
<div class="bg-surface-50-900 border border-surface-200-800 rounded-xl overflow-hidden">
<!-- Accordion header -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-3 cursor-pointer hover:bg-surface-100-900 transition-colors duration-200"
onclick={() => toggle_accordion(meeting.meeting_id)}
>
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">{meeting.room_name}</div>
<div class="text-sm opacity-60">{new Date(meeting.start_time).toLocaleString()}</div>
</div>
<div class="hidden sm:flex items-center gap-4 text-sm opacity-60 flex-none">
<span title="Duration">
<span class="fas fa-clock mr-1" aria-hidden="true"></span>
{meeting.final_duration}
</span>
<span title="Participant count">
<span class="fas fa-users mr-1" aria-hidden="true"></span>
{meeting.final_participant_count}
</span>
</div>
<div class="flex-none pl-2">
<span
class="fas transition-transform duration-200 inline-block"
class:fa-chevron-down={!open_accordions[meeting.meeting_id]}
class:fa-chevron-up={open_accordions[meeting.meeting_id]}
aria-hidden="true"
></span>
</div>
</div>
<!-- Mobile stats row -->
<div class="flex gap-4 text-sm opacity-60 mt-1 sm:hidden">
<span><span class="fas fa-clock mr-1" aria-hidden="true"></span>{meeting.final_duration}</span>
<span>
<span class="fas fa-users mr-1" aria-hidden="true"></span>
{meeting.final_participant_count}
{meeting.final_participant_count === 1 ? 'participant' : 'participants'}
</span>
</div>
</div>
<!-- Accordion body -->
{#if open_accordions[meeting.meeting_id]}
<div class="border-t border-surface-200-800 p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Event Timeline -->
<div>
<div class="text-xs uppercase tracking-wide opacity-40 mb-2">Event Timeline</div>
{#if meeting.events && meeting.events.length > 0}
<ul class="space-y-1">
{#each meeting.events as event, i (i)}
<li class="flex gap-2 items-start text-sm">
<span class="font-mono text-xs opacity-60 whitespace-nowrap mt-0.5">
[{new Date(event.timestamp).toLocaleTimeString()}]
</span>
<span>
<span class="font-semibold">
{ae_util.to_title_case(event.action.replace('jitsi_meeting_', ''))}
</span>
{#if event.details?.full_name}
<span class="opacity-60">{event.details.full_name}</span>
{/if}
</span>
</li>
{/each}
</ul>
{:else}
<p class="text-sm opacity-60 italic">No discrete events recorded.</p>
{/if}
</div>
<!-- Final Participants -->
<div>
<div class="text-xs uppercase tracking-wide opacity-40 mb-2">
Final Participants ({meeting.final_participant_count})
</div>
{#if meeting.final_participants && meeting.final_participants.length > 0}
<table class="w-full text-sm">
<thead>
<tr class="border-b border-surface-200-800">
<th class="text-left py-1 font-medium opacity-60">Name</th>
<th class="text-left py-1 font-medium opacity-60">Role</th>
</tr>
</thead>
<tbody>
{#each meeting.final_participants as participant (participant.displayName)}
<tr class="border-b border-surface-200-800 transition-colors duration-200 hover:bg-surface-100-900">
<td class="py-1">{participant.displayName}</td>
<td class="py-1">{ae_util.to_title_case(participant.role)}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="text-sm opacity-60 italic">No participant data available.</p>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<!-- Empty state -->
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-6 text-center">
{#if meetings_all.length > 0}
<div class="font-semibold">No meetings match the current filters</div>
<p class="text-sm opacity-60 mt-1">Try lowering the minimum participants or clearing the date range.</p>
<button
type="button"
onclick={reset_filters}
class="btn btn-sm preset-tonal-surface border border-surface-200-800 mt-3"
>
<span class="fas fa-times mr-1" aria-hidden="true"></span>
Reset Filters
</button>
{:else}
<div class="font-semibold">No Meeting Reports Found</div>
<p class="text-sm opacity-60 mt-1">There are no Jitsi activity logs to display.</p>
{/if}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,397 @@
<script lang="ts">
// ae_idaa_comp__jitsi_url_builder.svelte
// Builds and previews Jitsi iframe URLs for testing and Novi page configuration.
// Only shown to trusted_access users — not for general IDAA members.
// --- Environment presets ---
const BASE_URL_OPTIONS = [
{
label: 'Production',
value: 'https://sk-idaa.oneskyit.com/idaa/video_conferences'
},
{
label: 'Dev / Staging',
value: 'https://dev-idaa.oneskyit.com/idaa/video_conferences'
},
{
label: 'Local',
value: 'http://idaa.localhost:5173/idaa/video_conferences'
},
{ label: 'Custom…', value: 'custom' }
];
// --- State ---
let base_url_preset = $state(BASE_URL_OPTIONS[1].value); // dev by default
let base_url_custom = $state('');
let room_name = $state('IDAA-Test-Meeting');
let site_key = $state('restricted-access');
let uuid = $state('');
let is_moderator = $state(false);
let domain = $state('jitsi.dgrzone.com');
let start_muted = $state(true);
let start_hidden = $state(false);
let disable_incoming_msg = $state(true);
let disable_participant_joined = $state(false);
let disable_participant_left = $state(false);
let disable_reaction = $state(true);
let disable_raise_hand = $state(true);
// show_menu=true overrides the default iframe hide — for admins testing the embed
let show_ae_menu = $state(false);
let show_advanced = $state(false);
let show_sound = $state(false);
let output_mode = $state<'url' | 'iframe'>('url');
let copied = $state(false);
// --- Derived URL ---
let effective_base = $derived(
base_url_preset === 'custom' ? base_url_custom.trim() : base_url_preset
);
let built_url = $derived.by(() => {
if (!effective_base || !room_name.trim()) return '';
const p = new URLSearchParams();
if (uuid.trim()) p.set('uuid', uuid.trim());
p.set('iframe', 'true');
p.set('key', site_key.trim() || 'restricted-access');
p.set('room', room_name.trim());
if (is_moderator) p.set('moderator', 'true');
if (domain.trim() && domain.trim() !== 'jitsi.dgrzone.com')
p.set('domain', domain.trim());
if (start_muted) p.set('start_muted', 'true');
if (start_hidden) p.set('start_hidden', 'true');
if (disable_incoming_msg) p.set('incoming_msg_sound', 'true');
if (disable_participant_joined)
p.set('participant_joined_sound', 'true');
if (disable_participant_left) p.set('participant_left_sound', 'true');
if (disable_reaction) p.set('reaction_sound', 'true');
if (disable_raise_hand) p.set('raise_hand_sound', 'true');
// AE embed: iframe=true hides the menu by default; show_menu=true lets admins override
if (show_ae_menu) p.set('show_menu', 'true');
return `${effective_base}?${p.toString()}`;
});
let iframe_snippet = $derived(`<p>
<iframe
width="100%"
height="950"
id="ae_idaa_jitsi_meeting_iframe"
src="${built_url}"
style="min-height: 750px; height: min-content; max-height: 2048px"
class="ae_idaa_iframe"
allow="camera; microphone; fullscreen; display-capture; autoplay; clipboard-write"
allowfullscreen
></iframe>
</p>`);
let output = $derived(
output_mode === 'iframe' ? iframe_snippet : built_url
);
function copy_output() {
if (!output) return;
navigator.clipboard.writeText(output).then(() => {
copied = true;
setTimeout(() => (copied = false), 2000);
});
}
</script>
<div class="space-y-3 text-sm">
<!-- Environment + Room (always visible) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label
for="jub_base_url"
class="block text-xs uppercase tracking-wide opacity-40 mb-1"
>
Environment
</label>
<select
id="jub_base_url"
bind:value={base_url_preset}
class="border border-surface-200-800 rounded px-2 py-1 w-full bg-surface-50-950 text-sm"
>
{#each BASE_URL_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if base_url_preset === 'custom'}
<input
type="url"
bind:value={base_url_custom}
placeholder="https://…/idaa/video_conferences"
class="border border-surface-200-800 rounded px-2 py-1 w-full bg-surface-50-950 text-sm font-mono mt-1"
/>
{/if}
</div>
<div>
<label
for="jub_room"
class="block text-xs uppercase tracking-wide opacity-40 mb-1"
>
Room Name <span class="text-error-500">*</span>
</label>
<input
type="text"
id="jub_room"
bind:value={room_name}
placeholder="IDAA-Meeting-Room"
class="border border-surface-200-800 rounded px-2 py-1 w-full bg-surface-50-950 text-sm font-mono"
/>
</div>
</div>
<!-- UUID + Key -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label
for="jub_uuid"
class="block text-xs uppercase tracking-wide opacity-40 mb-1"
>
Novi UUID <span class="opacity-60">(blank = guest)</span>
</label>
<input
type="text"
id="jub_uuid"
bind:value={uuid}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
class="border border-surface-200-800 rounded px-2 py-1 w-full bg-surface-50-950 text-sm font-mono"
/>
</div>
<div>
<label
for="jub_key"
class="block text-xs uppercase tracking-wide opacity-40 mb-1"
>
Site Key
</label>
<input
type="text"
id="jub_key"
bind:value={site_key}
class="border border-surface-200-800 rounded px-2 py-1 w-full bg-surface-50-950 text-sm font-mono"
/>
</div>
</div>
<!-- Moderator toggle -->
<label class="flex items-center gap-2 cursor-pointer w-fit">
<input
type="checkbox"
bind:checked={is_moderator}
class="checkbox checkbox-sm"
/>
<span class="text-sm">Moderator</span>
<span class="text-xs opacity-40"
>(requests JWT, enables lobby + activity logging)</span
>
</label>
<!-- Advanced toggle -->
<button
type="button"
onclick={() => (show_advanced = !show_advanced)}
class="flex items-center gap-1 text-xs opacity-60 hover:opacity-100 transition-opacity"
>
<span
class="fas {show_advanced ? 'fa-chevron-up' : 'fa-chevron-down'}"
aria-hidden="true"
></span>
{show_advanced ? 'Hide' : 'Show'} advanced options
</button>
{#if show_advanced}
<div
class="border border-surface-200-800 rounded-xl p-3 space-y-3 bg-surface-100-900"
>
<!-- Domain -->
<div>
<label
for="jub_domain"
class="block text-xs uppercase tracking-wide opacity-40 mb-1"
>
Jitsi Domain
</label>
<input
type="text"
id="jub_domain"
bind:value={domain}
class="border border-surface-200-800 rounded px-2 py-1 w-full bg-surface-50-950 text-sm font-mono"
/>
</div>
<!-- AE menu — hides the AE nav chrome; useful when embedding in Novi or pages with their own navigation -->
<label class="flex items-center gap-2 cursor-pointer w-fit">
<input
type="checkbox"
bind:checked={show_ae_menu}
class="checkbox checkbox-sm"
/>
<span class="text-xs"
>Show AE system menu <span class="opacity-40"
>(show_menu=true)</span
></span
>
</label>
<!-- Start options -->
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={start_muted}
class="checkbox checkbox-sm"
/>
<span class="text-xs">Start muted</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={start_hidden}
class="checkbox checkbox-sm"
/>
<span class="text-xs">Start hidden (video off)</span>
</label>
</div>
<!-- Sound settings -->
<div>
<button
type="button"
onclick={() => (show_sound = !show_sound)}
class="flex items-center gap-1 text-xs opacity-60 hover:opacity-100 transition-opacity"
>
<span
class="fas {show_sound
? 'fa-chevron-up'
: 'fa-chevron-down'}"
aria-hidden="true"
></span>
{show_sound ? 'Hide' : 'Show'} sound settings
</button>
{#if show_sound}
<div class="flex flex-wrap gap-4 mt-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={disable_incoming_msg}
class="checkbox checkbox-sm"
/>
<span class="text-xs"
>Disable incoming msg sound</span
>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={disable_participant_joined}
class="checkbox checkbox-sm"
/>
<span class="text-xs"
>Disable participant joined sound</span
>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={disable_participant_left}
class="checkbox checkbox-sm"
/>
<span class="text-xs"
>Disable participant left sound</span
>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={disable_reaction}
class="checkbox checkbox-sm"
/>
<span class="text-xs">Disable reaction sound</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={disable_raise_hand}
class="checkbox checkbox-sm"
/>
<span class="text-xs">Disable raise hand sound</span
>
</label>
</div>
{/if}
</div>
</div>
{/if}
<!-- Output -->
<div class="border-t border-surface-200-800 pt-3 space-y-2">
<!-- Mode toggle -->
<div class="flex gap-2">
<button
type="button"
onclick={() => (output_mode = 'url')}
class="btn btn-sm {output_mode === 'url'
? 'preset-tonal-primary'
: 'preset-tonal-surface border border-surface-200-800'}"
>
<span class="fas fa-link mr-1" aria-hidden="true"></span>
URL
</button>
<button
type="button"
onclick={() => (output_mode = 'iframe')}
class="btn btn-sm {output_mode === 'iframe'
? 'preset-tonal-primary'
: 'preset-tonal-surface border border-surface-200-800'}"
>
<span class="fas fa-code mr-1" aria-hidden="true"></span>
iframe HTML
</button>
</div>
{#if built_url}
<div class="flex gap-1 items-stretch">
<textarea
readonly
rows={output_mode === 'iframe' ? 8 : 2}
value={output}
class="border border-surface-200-800 rounded px-2 py-1.5 w-full bg-surface-50-950 text-xs font-mono resize-none cursor-text"
onclick={(e) => (e.target as HTMLTextAreaElement).select()}
title="Click to select all"
></textarea>
<button
type="button"
onclick={copy_output}
title="Copy to clipboard"
class="btn btn-sm shrink-0 self-start {copied
? 'preset-tonal-success'
: 'preset-tonal-primary'} transition-colors"
>
<span
class="fas {copied ? 'fa-check' : 'fa-copy'}"
aria-hidden="true"
></span>
</button>
</div>
{#if output_mode === 'url'}
<a
href={built_url}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary-600 dark:text-primary-400 hover:underline inline-flex items-center gap-1"
>
<span class="fas fa-external-link-alt" aria-hidden="true"
></span>
Open in new tab
</a>
{/if}
{:else}
<p class="text-xs opacity-40 italic">
Fill in Room Name to generate a URL.
</p>
{/if}
</div>
</div>

View File

@@ -23,7 +23,7 @@
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events_functions';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Comp__event_obj_qry from './ae_idaa_comp__event_obj_qry.svelte';
import Comp__event_obj_li_wrapper from './ae_idaa_comp__event_obj_li_wrapper.svelte';

View File

@@ -160,7 +160,9 @@
meta_json: {
duration: meeting_duration,
participants: participants_array,
participant_count: participants_array.length
participant_count: participants_array.length,
// Verified Novi UUID of the moderator who started this log
moderator_novi_uuid: $idaa_loc.novi_uuid ?? null
}
};
try {
@@ -194,13 +196,15 @@
api.on('participantLeft', (participant: { id: string }) => {
console.log('Jitsi Event: participantLeft', participant);
if (meeting_participants.has(participant.id)) {
// Capture name before removing from map — it won't be available after delete
const p_info = meeting_participants.get(participant.id);
if (p_info) {
meeting_participants.delete(participant.id);
update_primary_activity_log();
// NOTE: We also want to log this as a discrete event
create_discrete_activity_log('jitsi_meeting_participant_left', 'participantLeft', {
attendee_id: participant.id,
full_name: p_info.displayName,
});
}
});
@@ -232,7 +236,16 @@
api.on('videoConferenceLeft', () => {
console.log('Jitsi Event: videoConferenceLeft');
if (duration_timer_id) clearInterval(duration_timer_id);
// meeting_duration = '00:00:00';
// Do a final update to the primary log so it captures the true end state,
// then log the meeting end as a discrete event for the timeline.
if (is_moderator && primary_activity_log_id) {
update_primary_activity_log();
create_discrete_activity_log('jitsi_meeting_end', 'videoConferenceLeft', {
final_duration: meeting_duration,
final_participant_count: meeting_participants.size,
});
}
});
api.on('readyToClose', () => {

View File

@@ -21,7 +21,7 @@
} from '$lib/stores/ae_stores';
import { core_func } from '$lib/ae_core/ae_core_functions';
import { idaa_loc, idaa_sess, idaa_slct } from '$lib/stores/ae_idaa_stores';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
interface Props {
/** @type {import('./$types').LayoutData} */

View File

@@ -1,120 +0,0 @@
<script lang="ts">
import { ae_util } from '$lib/ae_utils/ae_utils';
interface Props {
data: any;
}
let { data }: Props = $props();
let open_accordions = $state<{ [key: string]: boolean }>({});
function toggle_accordion(meeting_id: string) {
open_accordions[meeting_id] = !open_accordions[meeting_id];
}
</script>
<svelte:head>
<title>&AElig;: Jitsi Meeting Reports</title>
</svelte:head>
<div class="p-4 space-y-4">
<h1 class="h1">Jitsi Meeting Reports</h1>
{#await data.streamed.meetings}
<div class="space-y-4 animate-pulse">
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
{:then meetings}
{#if meetings && meetings.length > 0}
<div class="space-y-2">
{#each meetings as meeting (meeting.meeting_id)}
<div class="card card-hover">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<header class="card-header p-2 cursor-pointer" onclick={() => toggle_accordion(meeting.meeting_id)}>
<div class="flex justify-between items-center w-full">
<div class="flex-1">
<!-- NOTE: Normally I would the "h3" class, but Novi classes make things look odd. -->
<h3 class="text-base">{meeting.room_name}</h3>
<p class="text-sm text-gray-500">{new Date(meeting.start_time).toLocaleString()}</p>
</div>
<div class="flex-none flex items-center space-x-4 text-sm mr-4">
<span>Duration: {meeting.final_duration}</span>
<span>Participants: {meeting.final_participant_count}</span>
</div>
<div class="flex-none">
<span class="transition-transform duration-200" class:rotate-180={open_accordions[meeting.meeting_id]}>
<svg xmlns="https://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</span>
</div>
</div>
</header>
{#if open_accordions[meeting.meeting_id]}
<div class="p-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<!-- NOTE: Normally I would the "h4" class, but Novi classes make things look odd. -->
<h4 class="text-base">Event Timeline</h4>
{#if meeting.events && meeting.events.length > 0}
<ul class="list-disc list-inside space-y-2 mt-2">
{#each meeting.events as event, i (i)}
<li>
<span class="font-mono text-xs">[{new Date(event.timestamp).toLocaleTimeString()}]</span>
<span class="font-semibold">{ae_util.to_title_case(event.action.replace('jitsi_meeting_', ''))}</span>
{#if event.details.full_name}
- by {event.details.full_name}
{/if}
</li>
{/each}
</ul>
{:else}
<p class="text-gray-500 italic mt-2">No discrete events recorded.</p>
{/if}
</div>
<div>
<!-- NOTE: Normally I would the "h4" class, but Novi classes make things look odd. -->
<h4 class="text-base">Final Participants</h4>
{#if meeting.final_participants && meeting.final_participants.length > 0}
<div class="table-container mt-2">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{#each meeting.final_participants as participant (participant.displayName)}
<tr>
<td>{participant.displayName}</td>
<td>{ae_util.to_title_case(participant.role)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-gray-500 italic mt-2">No participant data available.</p>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="card p-4 text-center">
<h3 class="h3">No Meeting Reports Found</h3>
<p>There are no Jitsi activity logs to display.</p>
</div>
{/if}
{:catch error}
<div class="card p-4 bg-red-100 text-red-900 border-l-4 border-red-500">
<h3 class="h3 font-bold">Error Loading Reports</h3>
<p>An error occurred while fetching the meeting reports:</p>
<pre class="mt-2 text-xs overflow-auto">{error.message}</pre>
</div>
{/await}
</div>

View File

@@ -17,7 +17,7 @@
journals_slct,
journals_trig
} from '$lib/ae_journals/ae_journals_stores';
import Element_data_store from '$lib/elements/element_data_store.svelte';
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
import Help_tech from '$lib/app_components/e_app_help_tech.svelte';
// *** Setup Svelte properties

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import AE_Element_Data_Store from '$lib/elements/element_data_store.svelte';
import AE_Element_Data_Store from '$lib/elements/element_data_store_v3.svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_core } from '$lib/ae_core/db_core';
import { RefreshCw, Trash2 } from '@lucide/svelte';