22 Commits

Author SHA1 Message Date
Scott Idem
3553809f27 Add code field to archive content edit form and IDB
- Expose archive_content.code in edit form (trusted + edit_mode only)
- Add code to properties_to_save so it persists on every API load/save
- Add code field + index to Archive_Content Dexie interface (schema v2)
- Minor: center "Add" button rows in archive and content list components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:42:15 -04:00
Scott Idem
6e700e7b4d Remove redundant saving status from IDAA archive edit forms
XHR upload % in the button + disabled states now communicate
upload/save progress; the top Saving.../Finished saving block
is no longer needed (and its out:fade was broken on re-entry).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:13:23 -04:00
Scott Idem
d7b4d8c37c Hide drop zone container during upload to fix empty border flash
The outer bordered div was always in the DOM; only the label and input
inside were hidden during upload, leaving a visible empty dashed box.
Apply the same hidden guard to the container div.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:53:14 -04:00
Scott Idem
d9f704fe25 Enable upload progress tracking in both file upload components
Pass track_progress: true to post_object so the XHR path fires live
percent_completed events, making the upload % display functional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:46:42 -04:00
Scott Idem
af74b52481 Add XHR upload path with real-time progress tracking to post_object
New track_progress param (default false) switches to XMLHttpRequest for
form_data uploads so xhr.upload.onprogress can fire percent_completed
postMessages into api_upload_kv. fetch() has no upload progress events.
No retry loop on XHR path — silently retrying a large video upload is
bad UX; caller re-submits on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:46:38 -04:00
Scott Idem
730eea4ce7 Limit archive content upload to single file; improve file section UX
Restrict upload to one file (each archive content item maps to one file);
contextual toggle button text (Switch to Select / Switch to Upload);
swap FontAwesome upload icon for Lucide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:33:19 -04:00
Scott Idem
09f1a6ee57 Fix .ttf accept typo and remove $bindable from log_lvl in event file upload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:33:15 -04:00
Scott Idem
64c3afe564 Clean up hosted files upload component
Guard task_id effect against resetting mid-upload; add prevent_default
wrapper; add 20-min timeout for large video/audio files; add null result
guard before result[0]; fix for= attribute to use variable; console.error
on failure; remove dead params/comments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:33:12 -04:00
Scott Idem
54dfd734e6 Replace _random archive ID variants with V3 canonical field names
archive_obj.archive_id_random → .archive_id in load function and post-create
assignment; remove archive_id_random and hosted_file_id_random from editable
fields list — V3 returns the random string as the primary ID field directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:58:03 -04:00
Scott Idem
c7ebeebe29 Add dirty-tracking to Archive Content edit: disable Save, hide Cancel when unchanged
- ArchiveContentForm interface + factory for controlled input bindings
- obj_changed bindable prop wired to Cancel button visibility in parent page
- Split Save button: edit mode disables when clean, create mode always enabled
- Post-upload/select/remove syncs orig snapshot so file ops do not dirty the form
- Fix archive_content_id_random / archive_id_random → V3 field names in edit component
- Add missing file_extension field to ae_ArchiveContent type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:57:58 -04:00
Scott Idem
c71fc65be9 Fix archive content upload not patching record after file upload
Svelte 4 store nested property mutations don't call set()/update(), so
$effect on $idaa_slct never fired after upload. Replaced store property
binds with local $state variables that Svelte 5 proxies track natively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:26:01 -04:00
Scott Idem
8b7597906f Tighten Jitsi report table padding
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 15:05:05 -04:00
Scott Idem
c289268550 Fix Jitsi report dark surfaces
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:53:41 -04:00
Scott Idem
09a5178b89 Add Jitsi reports staff link
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:44:00 -04:00
Scott Idem
e64252b839 Refine Jitsi participant copy
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:39:45 -04:00
Scott Idem
25e35f6f96 Add Jitsi participant copy actions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:29:27 -04:00
Scott Idem
74bc3b3625 Use 1000-row Jitsi pages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:21:08 -04:00
Scott Idem
cd868460fe Fetch all Jitsi report rows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:03:47 -04:00
Scott Idem
6ebf4f125d Better styling for toggle
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 12:52:57 -04:00
Scott Idem
0ae8cf63d7 Improve Jitsi iframe toggle contrast
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 12:49:55 -04:00
Scott Idem
d32312653d Fix Jitsi report iframe title contrast
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 12:26:32 -04:00
Scott Idem
f5155eba50 New template page for IDAA and their Jitsi reports page. 2026-05-06 12:23:39 -04:00
18 changed files with 795 additions and 405 deletions

View File

@@ -479,6 +479,11 @@ Two display modes, toggled via a button in the page header:
Both modes use the same filtered data set — switching views does not reset filters.
### Dark Mode / Surface Safety
The page now uses explicit page and row surfaces so dark mode does not collapse into white-on-white
text in either the regular app or the Novi iframe.
### Filters
| Filter | Default | Logic |
@@ -533,6 +538,7 @@ Shown above the meeting list when data is loaded. Stats reflect the **filtered +
- **Total Duration** — sum of all session durations (HH:MM:SS)
In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available).
Each meeting instance keeps the full participant list visible; the **Copy names** button is edit-mode only so staff can grab the list for follow-up reports without exposing extra controls to normal viewers.
### Caching / Load Behavior
@@ -540,10 +546,23 @@ The page now reads cached `activity_log` rows from IndexedDB first, renders that
then refreshes from the API in the background. That keeps the report usable even when the network
round-trip is slow.
Both the cache path and the API refresh now page through the matching activity-log set in
`created_on DESC` order with a 1000-row page size before building the report. That avoids the old
"first 500 rows" behavior that could hide newer sessions if the log table grew large.
The report page keeps the newest session first in both the flat list and the grouped-by-room view;
grouped room rows are also sorted newest session first within each room.
### Jitsi URL Builder
Collapsible panel, visible to `trusted_access` users only. Generates properly-formatted Jitsi meeting URLs for IDAA rooms. Component: `ae_idaa_comp__jitsi_url_builder.svelte`.
### Video Conferences → Reports Link
Trusted Access users now get a footer link on the Video Conferences page that jumps back to the Jitsi Reports page. It preserves the current iframe context so the staff workflow stays inside the Novi embed.
**Future idea:** make that link include a `room=` query param for the current meeting so Jitsi Reports can auto-filter to that meeting instance, and have Reset clear that param again.
### Export
CSV and JSON export buttons in the page header export the **currently filtered + exclusion-applied** data set.
@@ -657,6 +676,10 @@ https://assets-staging.noviams.com/novi-core-assets/css/c/idaa/idaa.css — Boo
- `<section>` and heading elements may get unexpected margins/padding from Bootstrap's typography reset
- Class names `.field-input` and `.field-label` (used in the v2 edit form's scoped `<style>` block)
also exist in `idaa.css`'s date picker — Svelte's scoped attribute selector wins, but be aware
- In iframe widths near Tailwind `sm`, avoid hiding critical button labels behind breakpoint classes
and do not depend on color-only active states; Bootstrap's `.active`/button styling can make the
selected state nearly invisible unless the control uses an obvious fill/ring change plus
`aria-pressed`
**Mitigation:** The iframe CSS conflicts existed before v2 and are not new. The v2 form uses the
same Skeleton/Tailwind component classes as the rest of the app. Avoid using bare `<section>`,

View File

@@ -23,7 +23,10 @@ export const post_object = async function post_object({
// The task_id value should be a random string that is unique to the task. This is used to identify the task in the message event.
task_id = crypto.randomUUID(),
log_lvl = 0,
retry_count = 5
retry_count = 5,
// When true: use XHR instead of fetch so xhr.upload.onprogress can fire
// progress postMessages into api_upload_kv. Only meaningful for form_data uploads.
track_progress = false
}: {
api_cfg: any;
endpoint: string;
@@ -39,6 +42,7 @@ export const post_object = async function post_object({
task_id?: string;
log_lvl?: number;
retry_count?: number;
track_progress?: boolean;
}) {
if (log_lvl) {
console.log(
@@ -180,6 +184,21 @@ export const post_object = async function post_object({
fetch_method = api_cfg.fetch;
}
// XHR path — only for form_data uploads with progress tracking requested.
// fetch() has no upload progress events; XHR.upload.onprogress does.
if (track_progress && form_data) {
return _post_with_xhr({
url_str: url.toString(),
headers_cleaned,
form_data,
task_id,
endpoint,
timeout,
return_meta,
log_lvl
});
}
for (let attempt = 1; attempt <= retry_count; attempt++) {
try {
const controller = new AbortController();
@@ -405,3 +424,127 @@ export const post_object = async function post_object({
}
}
};
function _post_with_xhr({
url_str,
headers_cleaned,
form_data,
task_id,
endpoint,
timeout,
return_meta,
log_lvl
}: {
url_str: string;
headers_cleaned: key_val;
form_data: FormData;
task_id: string;
endpoint: string;
timeout: number;
return_meta: boolean;
log_lvl: number;
}): Promise<any> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url_str);
xhr.timeout = timeout;
// Apply auth/custom headers. Content-Type is intentionally omitted —
// the browser sets the multipart boundary automatically for FormData.
for (const [key, value] of Object.entries(headers_cleaned)) {
xhr.setRequestHeader(key, String(value));
}
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && typeof window !== 'undefined') {
const pct = Math.round((event.loaded / event.total) * 100);
try {
window.postMessage(
{
type: 'api_post_json_form',
status: 'uploading',
task_id,
endpoint,
size_total: event.total,
size_loaded: event.loaded,
percent_completed: pct,
progress: pct,
rate: 0
},
'*'
);
} catch (_) {}
}
};
xhr.ontimeout = () => {
console.error(`XHR upload timed out after ${timeout}ms. Endpoint: ${endpoint}`);
resolve(false);
};
xhr.onerror = () => {
console.error(`XHR upload network error. Endpoint: ${endpoint}`);
resolve(false);
};
xhr.onload = () => {
if (log_lvl)
console.log(`XHR response: status=${xhr.status} endpoint=${endpoint}`);
if (xhr.status === 401 || xhr.status === 403) {
console.warn(`XHR AUTH FAILURE (${xhr.status}): ${endpoint}`);
if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
resolve(false);
return;
}
if (xhr.status === 404) {
resolve(null);
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
console.error(`XHR upload failed: HTTP ${xhr.status}. Endpoint: ${endpoint}`);
resolve(false);
return;
}
// Fire completion postMessage (matches the fetch path shape)
try {
if (typeof window !== 'undefined') {
window.postMessage(
{
type: 'api_post_json_form',
status: 'complete',
task_id,
endpoint,
size_total: 0,
size_loaded: 0,
percent_completed: 100,
progress: 100,
rate: 0
},
'*'
);
}
} catch (_) {}
try {
const json = JSON.parse(xhr.responseText);
if (log_lvl > 1) console.log('XHR Response JSON:', json);
resolve(
return_meta
? json
: json.data !== undefined
? json.data
: json
);
} catch (e) {
console.error('XHR: Failed to parse response JSON.', e);
resolve(false);
}
};
xhr.send(form_data);
});
}

View File

@@ -192,7 +192,7 @@ export async function load_ae_obj_li__archive({
if (inc_content_li && archive_obj_li && Array.isArray(archive_obj_li)) {
for (let i = 0; i < archive_obj_li.length; i++) {
const archive_obj = archive_obj_li[i];
const archive_id = archive_obj.archive_id_random;
const archive_id = archive_obj.archive_id;
const content_li = await load_ae_obj_li__archive_content({
api_cfg: api_cfg,

View File

@@ -1,6 +1,5 @@
export const editable_fields__archive_content = [
'archive_id',
'archive_id_random',
'archive_content_type',
'name',
'description',
@@ -9,7 +8,6 @@ export const editable_fields__archive_content = [
'url',
'url_text',
'hosted_file_id',
'hosted_file_id_random',
'file_path',
'filename',
'file_extension',

View File

@@ -299,6 +299,7 @@ export const properties_to_save = [
'archive_id',
'archive_content_type',
'name',
'code',
'description',
'content_html',
'content_json',

View File

@@ -90,6 +90,7 @@ export interface Archive_Content {
archive_content_type: string;
name: string;
code?: null | string;
description?: null | string;
content_html?: null | string;
@@ -168,7 +169,28 @@ export class MySubClassedDexie extends Dexie {
tmp_sort_1, tmp_sort_2,
enable, hide, priority, sort, group, notes, created_on, updated_on, [group+priority+sort+updated_on]`
});
// v2: add code index to content table
this.version(2).stores({
archive: `
id, archive_id,
code,
account_id,
name,
original_datetime, original_timezone, original_location,
tmp_sort_1, tmp_sort_2,
enable, hide, priority, sort, group, notes, created_on, updated_on`,
content: `
id, archive_content_id,
archive_id,
archive_content_type,
name,
code,
hosted_file_id,
original_datetime, original_timezone, original_location,
[group+original_datetime],
tmp_sort_1, tmp_sort_2,
enable, hide, priority, sort, group, notes, created_on, updated_on, [group+priority+sort+updated_on]`
});
}
}

View File

@@ -49,7 +49,7 @@ let {
input_name = 'file_list',
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',
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',
class_li_default = 'flex flex-col gap-1 items-center justify-center w-full max-w-2xl mx-auto my-1',
class_li = '',
input_class_li = ['file_drop_area'],
@@ -66,7 +66,7 @@ let {
let task_id: string = $state('');
let input_file_list: any = $state(null);
let ae_promises: key_val = $state({}); // Promise<any>;
let ae_triggers: key_val = {};
// let ae_triggers: key_val = {};
let input_element_id = 'ae_comp__hosted_files_upload__input';
@@ -78,18 +78,22 @@ $effect(() => {
});
$effect(() => {
// Sync task_id with link_to_id prop so it resets when navigating to a different object.
task_id = link_to_id;
// Only sync task_id when idle — don't reset during an in-flight upload.
if (!ae_promises.upload__hosted_file_obj) {
task_id = link_to_id;
}
});
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
// *** Functions and Logic
async function handle_submit_form_files(event: SubmitEvent) {
console.log('*** handle_submit_form() ***');
event.preventDefault();
if (!event) {
return;
}
if (log_lvl) console.log('*** handle_submit_form() ***');
$ae_sess.files.disable_submit__hosted_file_obj = true;
$ae_sess.files.submit_status = 'saving';
@@ -113,42 +117,17 @@ async function handle_submit_form_files(event: SubmitEvent) {
file_input.files &&
file_input.files.length > 0
) {
task_id = link_to_id; // Ideally this should be the file hash, but we may be uploading multiple files at once. This should be done with a loop instead?
// Loop through each file and upload them individually in event.target[input_element_id].files
// The task_id should be the file hash.
// processed_file_list[i] has the file hash_sha256, hash_sha256_match, warnings, uploaded, uploaded_bytes, filename, and file_size_bytes.
for (let i = 0; i < file_input.files.length; i++) {
let tmp_file = file_input.files[i];
task_id = $ae_sess.files.processed_file_list[i].hash_sha256;
// hosted_file_results = await handle_input_upload_files([tmp_file], task_id);
hosted_file_results = await handle_input_upload_files({
input_upload_files: [tmp_file],
input_upload_files: [file_input.files[i]],
task_id: task_id
});
if (hosted_file_results) {
console.log(`hosted_file_results:`, hosted_file_results);
} else {
console.log(`hosted_file_results:`, hosted_file_results);
}
if (log_lvl > 1) console.log('hosted_file_results:', hosted_file_results);
}
// hosted_file_results = await handle_input_upload_files(event.target[input_element_id].files, task_id);
$ae_sess.files.processed_file_list = [];
$ae_sess = $ae_sess; // Is this needed? 2025-03-17
target.reset();
// await tick();
if (log_lvl) {
console.log(
`hosted_file_id_li: ${hosted_file_id_li}`,
hosted_file_id_li
);
} else if (log_lvl > 1) {
console.log('hosted_file_results:', hosted_file_results);
}
if (log_lvl) console.log('hosted_file_id_li:', hosted_file_id_li);
}
$ae_sess.files.disable_submit__hosted_file_obj = false;
@@ -164,120 +143,72 @@ async function handle_input_upload_files({
input_upload_files: any[];
task_id: string;
}) {
console.log('*** handle_input_upload_files() ***');
if (log_lvl) console.log('*** handle_input_upload_files() ***');
const form_data = new FormData();
form_data.append('account_id', $ae_loc.account_id);
form_data.append('link_to_type', link_to_type);
form_data.append('link_to_id', link_to_id);
for (let i = 0; i < input_upload_files.length; i++) {
form_data.append(`file_list`, input_upload_files[i]);
form_data.append('file_list', input_upload_files[i]);
}
// hash_sha256, uploaded, uploaded_bytes
// $ae_sess.files.processed_file_list[i] = {
// ...$ae_sess.files.processed_file_list[i],
// uploaded: $ae_sess.api_upload_kv[link_to_id].percent_completed,
// uploaded_bytes: $ae_sess.api_upload_kv[link_to_id].uploaded_bytes,
// };
let params = null;
let endpoint = '/v3/action/hosted_file/upload';
console.log(form_data);
params = null;
// Uncomment and the post_promise is not seen by the "await" below
// post_promise = await api.post_object({api_cfg: $cfg.api, endpoint: endpoint, params: params, data:form_data});
// Uncomment so that the post_promise is not seen by the "await" below
// Promise assigned to state so {#await ae_promises.upload__hosted_file_obj} in the
// template can track it. Using await here instead would hide the promise from the template.
ae_promises.upload__hosted_file_obj = api
.post_object({
api_cfg: $ae_api,
endpoint: endpoint,
// params: params,
endpoint: '/v3/action/hosted_file/upload',
form_data: form_data,
timeout: 1200000, // 20 min — large video/audio files
task_id: task_id,
track_progress: true,
log_lvl: log_lvl
// retry_count: 1,
})
.then(async function (result) {
// WARNING!!!! ONLY ONE FILE IS EXPECTED TO BE UPLOADED AT A TIME!!!
// NOTE: The upload endpoint always returns a list of successfully uploaded files. In this case we are only uploading one file and expecting a list of one item.
let x = 0;
console.log(result[x]);
let hosted_file_obj = result[x];
let hosted_file_id = hosted_file_obj.hosted_file_id;
.then(function (result) {
// Endpoint always returns a list; we upload one file at a time.
if (!result || !result[0]) {
console.error('Upload failed — no result returned.');
return false;
}
const hosted_file_obj = result[0];
const hosted_file_id = hosted_file_obj.hosted_file_id;
hosted_file_id_li.push(hosted_file_id);
hosted_file_obj_li.push(hosted_file_obj);
let hosted_file_data: key_val = {};
hosted_file_data['id'] = hosted_file_id; // Same as the hosted_file_id
hosted_file_data['hosted_file_id'] = hosted_file_id;
hosted_file_data['for_type'] = link_to_type;
hosted_file_data['for_id'] = link_to_id;
hosted_file_data['hash_sha256'] = hosted_file_obj.hash_sha256;
hosted_file_data['filename'] = hosted_file_obj.filename;
hosted_file_data['extension'] = hosted_file_obj.extension;
hosted_file_data['content_type'] = hosted_file_obj.content_type;
hosted_file_data['size'] = hosted_file_obj.size;
hosted_file_data['enable'] = true;
hosted_file_data['created_on'] = hosted_file_obj.created_on;
hosted_file_data['updated_on'] = hosted_file_obj.updated_on;
console.log(hosted_file_data);
const hosted_file_data: key_val = {
id: hosted_file_id,
hosted_file_id: hosted_file_id,
for_type: link_to_type,
for_id: link_to_id,
hash_sha256: hosted_file_obj.hash_sha256,
filename: hosted_file_obj.filename,
extension: hosted_file_obj.extension,
content_type: hosted_file_obj.content_type,
size: hosted_file_obj.size,
enable: true,
created_on: hosted_file_obj.created_on,
updated_on: hosted_file_obj.updated_on
};
hosted_file_obj_kv[hosted_file_id] = hosted_file_data;
if (log_lvl) {
console.log(`hosted_file_data:`, hosted_file_data);
}
if (log_lvl) console.log('hosted_file_data:', hosted_file_data);
return hosted_file_data;
// $ae_sess.files.new_upload_list[i].uploaded_bytes = 10; // fake 10 bytes at least...
// let event_file_id = await events_func.create_hosted_file_obj_from_hosted_file_async({
// api_cfg: $ae_api,
// hosted_file_id: hosted_file_id,
// data: event_file_data,
// log_lvl: log_lvl
// })
// .then(function (create_result) {
// console.log(create_result); // NOTE: This should be the event_file_id string
// // let event_file_id = create_result;
// return create_result;
// });
// return event_file_id;
})
// .then(function (hosted_file_data) {
// return hosted_file_data;
// })
.catch(function (error: any) {
console.log('Something went wrong.');
console.log(error);
console.error('Upload failed:', error);
return false;
})
.finally(function () {
$slct_trigger = 'load__hosted_file_obj_li';
});
if (log_lvl) {
console.log(`Waiting for upload__hosted_file_obj promise...`);
}
let hosted_file_result = ae_promises.upload__hosted_file_obj;
return hosted_file_result;
return ae_promises.upload__hosted_file_obj;
}
</script>
<!-- class:hidden={!$ae_loc.trusted_access} -->
<form onsubmit={handle_submit_form_files} class="{class_li_default} {class_li}">
<form onsubmit={prevent_default(handle_submit_form_files)} class="{class_li_default} {class_li}">
{#await ae_promises.upload__hosted_file_obj}
<div class="flex flex-row items-center justify-center gap-1 text-lg">
<Lucide.LoaderCircle class="m-1 animate-spin" />
@@ -291,7 +222,7 @@ async function handle_input_upload_files({
{/await}
<label
for="ae_comp__hosted_files_upload__input"
for={input_element_id}
class="svelte_input_file_label text-center"
class:hidden={$ae_sess.files.disable_submit__hosted_file_obj}>
{#if label}{@render label()}{:else}
@@ -337,9 +268,19 @@ async function handle_input_upload_files({
<button
type="submit"
class="btn btn-lg btn-primary preset-tonal-primary border-primary-500 hover:preset-tonal-success hover:border-success-500 w-54 border"
class="
btn btn-lg btn-primary
preset-tonal-primary
border border-primary-500
hover:preset-tonal-success hover:border-success-500
w-54
transition-all
"
class:opacity-30={$ae_sess.files.disable_submit__hosted_file_obj ||
$ae_sess.files.status__file_list != 'ready'}
disabled={$ae_sess.files.disable_submit__hosted_file_obj ||
$ae_sess.files.status__file_list != 'ready'}>
$ae_sess.files.status__file_list != 'ready'}
>
{#await ae_promises.upload__hosted_file_obj}
<Lucide.LoaderCircle class="m-1 animate-spin" />
<span class="">
@@ -350,18 +291,18 @@ async function handle_input_upload_files({
{/if}
</span>
{:then}
<Lucide.UploadCloud class="m-1" size={20} />
<Lucide.CloudUpload class="m-1" size={20} />
<span class="text-sm"> Upload </span>
{#if $ae_sess.files.processed_file_list?.length > 0}
<span class="ml-2 grow font-bold">
{#if $ae_sess.files.processed_file_list?.length > 0}
{$ae_sess.files.processed_file_list.length}
{$ae_sess.files.processed_file_list.length === 1
? 'file'
: 'files'}
{:else}
<span class="text-xs"> 0 </span>
{/if}
</span>
{:else}
<span class="text-xs"> none </span>
{/if}
{/await}
</button>
</form>

View File

@@ -29,6 +29,8 @@ interface MeetingReport {
events: MeetingEvent[];
}
const JITSI_REPORT_PAGE_SIZE = 1000;
// MariaDB TEXT columns come back as JSON strings from the API — parse safely.
function safe_parse_meta(raw: unknown): Record<string, unknown> {
if (!raw) return {};
@@ -89,6 +91,18 @@ function extract_participant_uuid(source: Record<string, unknown>): string {
return '';
}
function extract_flat_search_results(result: unknown): any[] {
if (Array.isArray(result)) return result;
if (
result &&
typeof result === 'object' &&
Array.isArray((result as { data?: unknown }).data)
) {
return (result as { data: any[] }).data;
}
return [];
}
/**
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
* grouped by meeting ID.
@@ -287,11 +301,9 @@ function build_jitsi_report_from_logs(
export async function load_jitsi_report_from_cache({
account_id,
limit = 500,
log_lvl = 0
}: {
account_id: string;
limit?: number;
log_lvl?: number;
}): Promise<MeetingReport[] | null> {
try {
@@ -303,8 +315,7 @@ export async function load_jitsi_report_from_cache({
log.name === 'jitsi_meeting_event' ||
log.name === 'jitsi_meeting_stats'
)
.limit(limit)
.toArray();
.sortBy('created_on');
if (cached_logs.length === 0) return null;
if (
@@ -324,7 +335,7 @@ export async function load_jitsi_report_from_cache({
`Jitsi report cache hit: ${cached_logs.length} activity_log rows`
);
}
return build_jitsi_report_from_logs(cached_logs, log_lvl);
return build_jitsi_report_from_logs(cached_logs.reverse(), log_lvl);
} catch (err) {
if (log_lvl) console.warn('Jitsi report cache read failed.', err);
return null;
@@ -336,7 +347,7 @@ export async function qry__jitsi_report({
account_id,
enabled = 'all',
hidden = 'all',
limit = 500,
limit = JITSI_REPORT_PAGE_SIZE,
try_cache = true,
log_lvl = 0
}: {
@@ -359,27 +370,32 @@ export async function qry__jitsi_report({
and: [{ field: 'account_id_random', op: 'eq', value: account_id }]
};
const result = await api.search_ae_obj({
api_cfg: api_cfg,
obj_type: 'activity_log',
search_query,
headers: { 'x-account-id': account_id },
enabled,
hidden,
limit,
log_lvl: log_lvl
});
const flat_log_list: any[] = [];
const order_by_li: Record<string, 'ASC' | 'DESC'> = {
created_on: 'DESC'
};
let offset = 0;
// Handle potential V3 API envelope
let flat_log_list: any[] = [];
if (Array.isArray(result)) {
flat_log_list = result;
} else if (
result &&
typeof result === 'object' &&
Array.isArray((result as any).data)
) {
flat_log_list = (result as any).data;
while (true) {
const result = await api.search_ae_obj({
api_cfg: api_cfg,
obj_type: 'activity_log',
search_query,
headers: { 'x-account-id': account_id },
enabled,
hidden,
order_by_li,
limit,
offset,
log_lvl: log_lvl
});
const page = extract_flat_search_results(result);
if (page.length === 0) break;
flat_log_list.push(...page);
if (page.length < limit) break;
offset += limit;
}
if (

View File

@@ -863,6 +863,7 @@ export interface ae_ArchiveContent extends ae_BaseObj {
hosted_file_id_random?: string; // NO LONGER USE "_random"
filename?: string;
file_extension?: string;
subdirectory_path?: string;
}

View File

@@ -45,13 +45,13 @@ interface Props {
}
let {
log_lvl = $bindable(0),
log_lvl = 0,
link_to_type,
link_to_id,
input_name = 'file_list',
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',
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',
class_li_default = 'flex flex-col gap-1 items-center justify-center w-full max-w-2xl mx-auto my-1',
class_li = '',
input_class_li = ['file_drop_area'],
@@ -155,6 +155,7 @@ async function handle_input_upload_files({
form_data: form_data,
timeout: 1200000, // 20 minutes — large presentation files can be very slow to upload
task_id: task_id,
track_progress: true,
log_lvl: log_lvl
})
.then(async function (result) {
@@ -218,7 +219,8 @@ async function handle_input_upload_files({
cursor-pointer rounded-lg border-2
border-dashed p-1
transition-colors hover:border-2
">
"
class:hidden={$events_sess.files.disable_submit__event_file_obj}>
<label
for={input_element_id}
class="

View File

@@ -51,6 +51,8 @@ import { page } from '$app/state';
// let ae_tmp: key_val = {};
// let ae_triggers: key_val = {};
let archive_content_edit_dirty = $state(false);
// *** Quickly pull out data from parent(s)
let ae_acct = $derived(data[data.account_id]);
$effect(() => {
@@ -502,8 +504,8 @@ onDestroy(() => {
{#snippet header()}
<div class="flex w-full flex-row items-center justify-between">
<h3 class="text-lg font-semibold">
{#if $ae_loc.trusted_access}
<!-- <div class="ae_options"> -->
{#if $ae_loc.trusted_access && (!$idaa_slct.archive_content_id || archive_content_edit_dirty)}
<!-- Hidden in edit mode when nothing has changed — no unsaved changes to cancel -->
<button
type="button"
onclick={() => {
@@ -517,7 +519,6 @@ onDestroy(() => {
title={`View meeting: ${$lq__archive_content_obj?.name}`}>
<span class="fas fa-eye m-1"></span> Cancel
</button>
<!-- </div> -->
{/if}
<span class="text-sm text-gray-500"> Edit Content: </span>
@@ -526,7 +527,7 @@ onDestroy(() => {
</div>
{/snippet}
<Archive_content_obj_id_edit {lq__archive_content_obj} />
<Archive_content_obj_id_edit {lq__archive_content_obj} bind:obj_changed={archive_content_edit_dirty} />
{#snippet footer()}
<button

View File

@@ -1,7 +1,7 @@
<script lang="ts">
// *** Import Svelte core
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import * as Lucide from 'lucide-svelte';
// *** Import Aether core variables and functions
import type { key_val } from '$lib/stores/ae_stores';
@@ -28,9 +28,10 @@ import Element_manage_hosted_file_li_wrap from '$lib/elements/element_manage_hos
interface Props {
log_lvl?: number;
lq__archive_content_obj: any;
obj_changed?: boolean;
}
let { log_lvl = 0, lq__archive_content_obj }: Props = $props();
let { log_lvl = 0, lq__archive_content_obj, obj_changed = $bindable(false) }: Props = $props();
let create_archive_content_obj_promise: any;
let delete_archive_content_obj_promise: any;
@@ -42,13 +43,17 @@ let prom_api__archive_content_obj__hosted_file: any = $state();
let description_new_html = $state(
$idaa_slct.archive_content_obj?.description ?? ''
);
let description_changed = $state(false);
let notes_new_html = $state($idaa_slct.archive_content_obj?.notes ?? '');
let notes_changed = $state(false);
let disable_submit_btn = $state(false);
let slct_hosted_file_kv: key_val = $state({});
// Local $state for upload bind targets — Svelte 4 store mutations don't trigger $effect,
// so we can't bind directly to $idaa_slct.archive_content_obj.* for reactivity.
let upload_complete_local: boolean = $state(false);
let hosted_file_id_li_local: string[] = $state([]);
let hosted_file_obj_li_local: key_val[] = $state([]);
if ($idaa_slct.archive_content_id) {
console.log(
`Archive Content ID selected: ${$idaa_slct.archive_content_id}`
@@ -77,7 +82,7 @@ if ($idaa_slct.archive_content_id) {
} else {
$idaa_slct.archive_content_id = null;
$idaa_slct.archive_content_obj = {
archive_id_random: null,
archive_id: null,
// archive_content_id_random: null,
archive_content_type: 'hosted_file',
name: null,
@@ -108,6 +113,58 @@ if ($idaa_slct.archive_content_id) {
);
}
// Form shape for controlled inputs — enables dirty detection via snapshot comparison.
// Initialized AFTER the init block above so new-item defaults are in place.
interface ArchiveContentForm {
name: string;
code: string;
archive_content_type: string;
filename: string;
file_extension: string;
original_datetime_date: string;
original_datetime_time: string;
original_timezone: string;
original_location: string;
hide: boolean;
priority: boolean | null;
enable: boolean;
sort: number | null;
group: string;
}
function create_archive_content_form(obj: any, current_timezone = ''): ArchiveContentForm {
return {
name: obj?.name ?? '',
code: obj?.code ?? '',
archive_content_type: obj?.archive_content_type ?? 'hosted_file',
filename: obj?.filename ?? '',
file_extension: obj?.file_extension ?? '',
original_datetime_date: obj?.original_datetime
? ae_util.iso_datetime_formatter(obj.original_datetime, 'date_iso')
: '',
original_datetime_time: obj?.original_datetime
? ae_util.iso_datetime_formatter(obj.original_datetime, 'time_iso')
: '',
original_timezone: obj?.original_timezone ?? current_timezone,
original_location: obj?.original_location ?? '',
hide: obj?.hide ?? false,
priority: obj?.priority ?? null,
enable: obj?.enable ?? true,
sort: obj?.sort ?? null,
group: obj?.group ?? ''
};
}
let archive_content_form = $state(
create_archive_content_form($idaa_slct.archive_content_obj, $ae_loc.current_timezone)
);
let orig_archive_content_form = $state(
create_archive_content_form($idaa_slct.archive_content_obj, $ae_loc.current_timezone)
);
// Plain (non-reactive) snapshots for TipTap content comparison
let orig_description_html = $idaa_slct.archive_content_obj?.description ?? '';
let orig_notes_html = $idaa_slct.archive_content_obj?.notes ?? '';
// Timezone lookup — reactive IDB query; background refresh handled by liveQuery + TTL
// Sort: sort DESC (higher = first, NULL=0 last), then name ASC — matches Aether backend convention.
const lq__lu_time_zone = liveQuery(() =>
@@ -132,6 +189,13 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
};
}
function mark_archive_content_form_saved() {
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
orig_description_html = description_new_html;
orig_notes_html = notes_new_html;
obj_changed = false;
}
async function handle_submit_form(event: SubmitEvent) {
if (log_lvl > 1) {
console.log('*** handle_submit_form() ***');
@@ -157,10 +221,11 @@ async function handle_submit_form(event: SubmitEvent) {
let archive_content_do: key_val = {};
if (!$idaa_slct.archive_content_id) {
archive_content_do['archive_id_random'] = $idaa_slct.archive_id;
archive_content_do['archive_id'] = $idaa_slct.archive_id;
}
archive_content_do['name'] = archive_content_di.name;
archive_content_do['code'] = archive_content_di?.code?.trim() || null;
// Check if the description_new_html exists and is a string
if (typeof description_new_html === 'string') {
@@ -247,21 +312,16 @@ async function handle_submit_form(event: SubmitEvent) {
}
$idaa_slct.archive_content_id =
archive_content_obj_create_result.archive_content_id_random;
// $idaa_slct.archive_content_obj = await $lq__archive_content_obj;
// $idaa_slct.archive_content_obj.hosted_file_id_li = [];
// $idaa_slct.archive_content_obj.hosted_file_obj_li = [];
// $idaa_slct.archive_content_obj.upload_complete = false;
// $idaa_slct.archive_content_obj.description_new_html = '';
archive_content_obj_create_result.archive_content_id;
$idaa_slct.archive_content_obj =
archive_content_obj_create_result;
$idaa_slct.archive_content_obj.archive_content_id =
archive_content_obj_create_result.archive_content_id_random; // This is because we need use the string ID, not int ID.
$idaa_slct.archive_content_obj.upload_complete = false;
$idaa_slct.archive_content_obj.hosted_file_id_li = [];
$idaa_slct.archive_content_obj.hosted_file_obj_li = [];
mark_archive_content_form_saved();
return archive_content_obj_create_result;
})
.catch(function (error: any) {
@@ -299,6 +359,7 @@ async function handle_submit_form(event: SubmitEvent) {
// We need to do all of this since the DB object has changed and the SLCT object does automatically update (yet...??? Svelte 5?).
// $idaa_slct.archive_content_obj = await $lq__archive_content_obj;
mark_archive_content_form_saved();
$idaa_slct.archive_content_id = null;
$idaa_slct.archive_content_obj = {};
$idaa_sess.archives.show__modal_edit__archive_content_id = false;
@@ -344,6 +405,12 @@ async function handle_hosted_files_uploaded(
) {
$idaa_slct.archive_content_obj.hosted_file_obj_li = [];
}
// Sync form and snapshot so a successful upload doesn't mark the form as dirty.
archive_content_form.filename = archive_content_obj_update_result?.filename ?? '';
archive_content_form.file_extension = archive_content_obj_update_result?.file_extension ?? '';
archive_content_form.archive_content_type = 'hosted_file';
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
})
.catch(function (error: any) {
console.log('Something went wrong.');
@@ -352,16 +419,37 @@ async function handle_hosted_files_uploaded(
});
}
// Dirty detection — edit mode only. Create mode Save is always available.
$effect(() => {
if (!$idaa_slct.archive_content_id) return;
const form_changed =
JSON.stringify(archive_content_form) !== JSON.stringify(orig_archive_content_form);
const has_changes =
form_changed ||
description_new_html !== orig_description_html ||
notes_new_html !== orig_notes_html;
if (!obj_changed && has_changes) {
obj_changed = true;
} else if (obj_changed && !has_changes) {
obj_changed = false;
}
});
$effect(() => {
if (
$idaa_slct.archive_content_obj?.upload_complete &&
$idaa_slct.archive_content_obj?.hosted_file_id_li?.length &&
$idaa_slct.archive_content_obj.hosted_file_obj_li?.length
upload_complete_local &&
hosted_file_id_li_local.length &&
hosted_file_obj_li_local.length
) {
handle_hosted_files_uploaded(
$idaa_slct.archive_content_obj.hosted_file_id_li,
$idaa_slct.archive_content_obj.hosted_file_obj_li
hosted_file_id_li_local,
hosted_file_obj_li_local
);
upload_complete_local = false;
hosted_file_id_li_local = [];
hosted_file_obj_li_local = [];
}
});
@@ -405,6 +493,12 @@ $effect(() => {
) {
$idaa_slct.archive_content_obj.hosted_file_obj_li = [];
}
// Sync form and snapshot so selecting an existing file doesn't mark form as dirty.
archive_content_form.filename = archive_content_obj_update_result?.filename ?? '';
archive_content_form.file_extension = archive_content_obj_update_result?.file_extension ?? '';
archive_content_form.archive_content_type = 'hosted_file';
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
})
.catch(function (error: any) {
console.log('Something went wrong.');
@@ -429,20 +523,6 @@ $effect(() => {
<!-- bind:clientHeight={ae_iframe_height} -->
<form onsubmit={prevent_default(handle_submit_form)} class="space-y-1">
{#await prom_api__archive_content_obj}
<div class="awaiting alert_msg_pulse" out:fade={{ duration: 2000 }}>
Saving...
</div>
{:then}
{#if prom_api__archive_content_obj}
<div class="awaiting" out:fade={{ duration: 2000 }}>
Finished saving
</div>
{:else}
<!-- <div class="awaiting" out:fade={{ duration: 2000 }}>Nothing here yet</div> -->
{/if}
{/await}
<!-- <h3>Archive Content</h3> -->
<div
@@ -456,9 +536,7 @@ $effect(() => {
name="name"
required
max="200"
value={$idaa_slct.archive_content_obj?.name
? $idaa_slct.archive_content_obj.name
: ''}
bind:value={archive_content_form.name}
autocomplete="off"
class="
input preset-tonal-surface
@@ -484,43 +562,16 @@ $effect(() => {
<select
id="archive_content_type"
name="archive_content_type"
bind:value={
$idaa_slct.archive_content_obj.archive_content_type
}
bind:value={archive_content_form.archive_content_type}
class="select w-52">
<option value="">-- None --</option>
<option value="hosted_file" selected
>Hosted File in Æ</option>
<option value="hosted_file">Hosted File in Æ</option>
<option value="html">Hosted HTML in Æ</option>
<option value="json">Hosted JSON in Æ</option>
<option value="url">External URL</option>
<option value="other">Other</option>
</select>
</label>
<!-- <fieldset class="">
<legend class="">Public Access with Rotating Access Key/Passcode</legend>
<label for="enable_for_public_no" class="">No, disable public access
<input
type="radio"
class="radio"
id="enable_for_public_no"
name="enable_for_public"
value={false}
bind:group={$idaa_slct.archive_content_obj.enable_for_public}
>
</label>
<label for="enable_for_public_yes" class="">Yes, allow public access
<input
type="radio"
class="radio"
id="enable_for_public_yes"
name="enable_for_public"
value={true}
bind:group={$idaa_slct.archive_content_obj.enable_for_public}
>
</label>
</fieldset> -->
</div>
{#if $idaa_slct.archive_content_id}
@@ -552,7 +603,12 @@ $effect(() => {
}
}}>
<span class="fas fa-exchange-alt m-1"></span>
Upload/Select
{#if $ae_sess.files.add_to_use_files_method ==
'upload'}
Switch to Select
{:else}
Switch to Upload
{/if}
</button>
<div
@@ -564,34 +620,27 @@ $effect(() => {
link_to_type="archive_content"
link_to_id={$idaa_slct.archive_content_obj
.archive_content_id}
bind:hosted_file_id_li={
$idaa_slct.archive_content_obj
.hosted_file_id_li
}
bind:hosted_file_obj_li={
$idaa_slct.archive_content_obj
.hosted_file_obj_li
}
bind:upload_complete={
$idaa_slct.archive_content_obj
.upload_complete
}
multiple={false}
bind:hosted_file_id_li={hosted_file_id_li_local}
bind:hosted_file_obj_li={hosted_file_obj_li_local}
bind:upload_complete={upload_complete_local}
log_lvl={1}>
{#snippet label()}
<div
class="flex flex-col items-center gap-2 py-2">
<div
<!-- <div
class="bg-primary-500/10 text-primary-500 rounded-full p-3">
<span class="fas fa-upload fa-lg"
></span>
</div>
</div> -->
<div class="text-center">
<p
class="text-primary-500 font-bold">
<Lucide.Upload size="1.25em" class="inline-block mr-1" />
Upload archive files
</p>
<p class="text-xs opacity-60">
Recommended: pptx, key, PDF,
Recommended: pptx, key, pdf,
mp3, mp4, docx
</p>
</div>
@@ -605,11 +654,11 @@ $effect(() => {
.add_to_use_files_method != 'select'}
class="">
<Element_manage_hosted_file_li_wrap
link_to_type={'account'}
link_to_type="account"
link_to_id={$ae_loc?.account_id}
allow_basic={true}
allow_moderator={true}
class_li={''}
class_li=""
bind:slct_hosted_file_kv
bind:slct_hosted_file_id
bind:slct_hosted_file_obj />
@@ -639,7 +688,7 @@ $effect(() => {
fake_delete: false,
log_lvl: log_lvl
})
.then(function (delete_result) {
.then(function () {
// Second - If deleted, then update the archive_content_obj
console.log(
`File removed. Now update the archive_content_obj`
@@ -664,10 +713,7 @@ $effect(() => {
log_lvl: log_lvl
}
)
.then(
function (
archive_content_obj_update_result
) {
.then(function () {
// We need to do all of this since the DB object has changed and the SLCT object does automatically update (yet...??? Svelte 5?).
// $idaa_slct.archive_content_obj = $lq__archive_content_obj;
}
@@ -705,6 +751,11 @@ $effect(() => {
[];
$idaa_slct.archive_content_obj.hosted_file_obj_li =
[];
// Sync form snapshot after file removal
archive_content_form.filename = '';
archive_content_form.file_extension = '';
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
});
}
}}
@@ -715,35 +766,15 @@ $effect(() => {
<span class="fas fa-trash-alt m-1"></span>
Remove File
{/await}
<!-- <span class="fas fa-trash-alt m-1"></span>
Remove File -->
</button>
<!-- <label for="file_path">File Path
{#if !$ae_loc.administrator_access}
<span class="fas fa-lock" title="Field is locked"></span>
{:else}
<span class="fas fa-unlock" title="Field is unlocked"></span>
{/if}
<input
type="text"
id="file_path"
name="file_path"
value={($idaa_slct.archive_content_obj.file_path ? $idaa_slct.archive_content_obj.file_path : '')}
readonly={!$ae_loc.administrator_access}
class="input w-full"
>
</label> -->
<label for="filename"
>Filename
<input
type="text"
id="filename"
name="filename"
value={$idaa_slct.archive_content_obj.filename
? $idaa_slct.archive_content_obj.filename
: 'unknown'}
bind:value={archive_content_form.filename}
class="input w-full" />
</label>
@@ -761,9 +792,7 @@ $effect(() => {
type="text"
id="file_extension"
name="file_extension"
value={$idaa_slct.archive_content_obj.file_extension
? $idaa_slct.archive_content_obj.file_extension
: 'ext'}
bind:value={archive_content_form.file_extension}
readonly={!$ae_loc.administrator_access}
class="input w-24" />
</label>
@@ -787,24 +816,14 @@ $effect(() => {
type="date"
id="original_datetime_date"
name="original_datetime_date"
value={$idaa_slct.archive_content_obj.original_datetime
? ae_util.iso_datetime_formatter(
$idaa_slct.archive_content_obj.original_datetime,
'date_iso'
)
: ''}
bind:value={archive_content_form.original_datetime_date}
placeholder="YYYY-MM-DD"
class="input w-48" />
<input
type="time"
id="original_datetime_time"
name="original_datetime_time"
value={$idaa_slct.archive_content_obj.original_datetime
? ae_util.iso_datetime_formatter(
$idaa_slct.archive_content_obj.original_datetime,
'time_iso'
)
: ''}
bind:value={archive_content_form.original_datetime_time}
placeholder="HH:MM AM/PM"
class="input w-48" />
</label>
@@ -816,11 +835,7 @@ $effect(() => {
<select
id="original_timezone"
name="original_timezone"
value={$idaa_slct.archive_content_obj
.original_timezone
? $idaa_slct.archive_content_obj
.original_timezone
: $ae_loc.current_timezone}
bind:value={archive_content_form.original_timezone}
class="select w-56"
title="Select the original timezone">
<option value="">-- None --</option>
@@ -834,11 +849,7 @@ $effect(() => {
<input
type="text"
name="timezone"
value={$idaa_slct.archive_content_obj
.original_timezone
? $idaa_slct.archive_content_obj
.original_timezone
: $ae_loc.current_timezone}
bind:value={archive_content_form.original_timezone}
class="input w-56" />
{/if}
</label>
@@ -850,7 +861,7 @@ $effect(() => {
type="text"
id="original_location"
name="original_location"
value={$idaa_slct.archive_content_obj.original_location}
bind:value={archive_content_form.original_location}
class="input w-96" />
</label>
</div>
@@ -880,6 +891,19 @@ $effect(() => {
<div
class="space-y-3"
class:hidden={!$idaa_loc.archives.show__admin_options}>
{#if $ae_loc.edit_mode}
<label class="text-sm font-semibold" for="code">
Code
<input
type="text"
id="code"
name="code"
bind:value={archive_content_form.code}
maxlength="100"
class="input preset-tonal-surface form-control w-48"
placeholder="short-code" />
</label>
{/if}
<span
class="flex w-full flex-col flex-wrap items-center justify-center gap-2 md:flex-row md:justify-stretch">
<span
@@ -895,9 +919,7 @@ $effect(() => {
id="hide_yes"
name="hide"
value={true}
bind:group={
$idaa_slct.archive_content_obj.hide
}
bind:group={archive_content_form.hide}
class="radio form-check-input" />
<label for="hide_yes">Yes</label>
</div>
@@ -907,9 +929,7 @@ $effect(() => {
id="hide_no"
name="hide"
value={false}
bind:group={
$idaa_slct.archive_content_obj.hide
}
bind:group={archive_content_form.hide}
class="radio form-check-input" />
<label for="hide_no">No</label>
</div>
@@ -926,10 +946,7 @@ $effect(() => {
id="priority_yes"
name="priority"
value={true}
bind:group={
$idaa_slct.archive_content_obj
.priority
}
bind:group={archive_content_form.priority}
class="radio form-check-input" />
<label for="priority_yes">Yes</label>
</div>
@@ -939,10 +956,7 @@ $effect(() => {
id="priority_no"
name="priority"
value={false}
bind:group={
$idaa_slct.archive_content_obj
.priority
}
bind:group={archive_content_form.priority}
class="radio form-check-input" />
<label for="priority_no">No</label>
</div>
@@ -960,7 +974,7 @@ $effect(() => {
>Sort <input
type="number"
name="sort"
value={$idaa_slct.archive_content_obj.sort}
bind:value={archive_content_form.sort}
class="input preset-tonal-surface form-control w-24" /></label>
<label
@@ -968,8 +982,7 @@ $effect(() => {
>Group <input
type="text"
name="group"
value={$idaa_slct.archive_content_obj
.group ?? ''}
bind:value={archive_content_form.group}
max="100"
class="input preset-tonal-surface form-control w-40" /></label>
</span>
@@ -988,10 +1001,7 @@ $effect(() => {
id="enable_yes"
name="enable"
value={true}
bind:group={
$idaa_slct.archive_content_obj
.enable
}
bind:group={archive_content_form.enable}
class="radio form-check-input" />
<label for="enable_yes">Yes</label>
</div>
@@ -1001,10 +1011,7 @@ $effect(() => {
id="enable_no"
name="enable"
value={false}
bind:group={
$idaa_slct.archive_content_obj
.enable
}
bind:group={archive_content_form.enable}
class="radio form-check-input" />
<label for="enable_no">No</label>
</div>
@@ -1014,7 +1021,7 @@ $effect(() => {
<input
type="hidden"
name="enable"
value={$idaa_slct.archive_content_obj.enable} />
value={archive_content_form.enable} />
{/if}
</span>
@@ -1049,22 +1056,37 @@ $effect(() => {
d-flex align-items-center justify-content-between flex w-full flex-row
items-center justify-between gap-1
">
<button
type="submit"
disabled={disable_submit_btn}
class="
novi_btn
btn btn-md preset-tonal-primary preset-outlined-primary-800-200 hover:preset-filled-primary-200-800
transition
">
{#await prom_api__archive_content_obj}
<span class="fas fa-spinner fa-spin m-1"></span> Saving
{:then}
<span class="fas fa-save m-1"></span> Save Changes
{/await}
<!-- <span class="fas fa-check m-1"></span> -->
<!-- Save Archive Content -->
</button>
{#if $idaa_slct.archive_content_id}
<button
type="submit"
disabled={disable_submit_btn || !obj_changed}
class="
novi_btn
btn btn-md preset-tonal-primary preset-outlined-primary-800-200 hover:preset-filled-primary-200-800
transition
">
{#await prom_api__archive_content_obj}
<span class="fas fa-spinner fa-spin m-1"></span> Saving
{:then}
<span class="fas fa-save m-1"></span> Save Changes
{/await}
</button>
{:else}
<button
type="submit"
disabled={disable_submit_btn}
class="
novi_btn
btn btn-md preset-tonal-warning hover:preset-filled-warning-200-800
transition
">
{#await prom_api__archive_content_obj}
<span class="fas fa-spinner fa-spin m-1"></span> Saving
{:then}
<span class="fas fa-save m-1"></span> Save New Content
{/await}
</button>
{/if}
{#if $idaa_slct.archive_content_id && !$idaa_slct.archive_content_obj?.hosted_file_id}
<button

View File

@@ -67,7 +67,7 @@ file_icons['zip'] = 'file-archive';
class="archive_list flex w-full flex-col items-center justify-center gap-2">
{#if $ae_loc.trusted_access && $ae_loc.edit_mode}
<div
class="mb-2 flex w-full max-w-(--breakpoint-lg) flex-row items-center justify-start px-2">
class="mb-2 flex w-full max-w-(--breakpoint-lg) flex-row items-center justify-center px-2">
<button
type="button"
disabled={creating}

View File

@@ -2,8 +2,6 @@
// *** Import Svelte core
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
// *** Import Aether core variables and functions
import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
@@ -178,7 +176,7 @@ async function handle_submit_form(event: any) {
}
$idaa_slct.archive_id =
archive_obj_create_result.archive_id_random;
archive_obj_create_result.archive_id;
$idaa_slct.archive_obj = archive_obj_create_result;
return update_archive_obj_promise;
@@ -280,20 +278,6 @@ async function handle_delete_archive_obj({
class:ae_create={!$idaa_slct.archive_id}
bind:clientHeight={$ae_loc.iframe_height_modal_body}>
<form onsubmit={prevent_default(handle_submit_form)} class="space-y-1">
{#await update_archive_obj_promise}
<div class="awaiting alert_msg_pulse" out:fade={{ duration: 2000 }}>
Saving...
</div>
{:then}
{#if update_archive_obj_promise}
<div class="awaiting" out:fade={{ duration: 2000 }}>
Finished saving
</div>
{:else}
<!-- <div class="awaiting" out:fade={{ duration: 2000 }}>Nothing here yet</div> -->
{/if}
{/await}
<!-- <h2 class="h2">Archive</h2> -->
<div

View File

@@ -68,7 +68,7 @@ let sorted_archive_obj_li = $derived.by(() => {
flex w-full flex-col items-center justify-center gap-2
">
<div
class="mb-2 flex w-full max-w-(--breakpoint-lg) flex-row items-center justify-between gap-2 px-2">
class="mb-2 flex w-full max-w-(--breakpoint-lg) flex-row items-center justify-center gap-2 px-2">
{#if $ae_loc.trusted_access && $ae_loc.edit_mode}
<button
type="button"
@@ -114,8 +114,6 @@ let sorted_archive_obj_li = $derived.by(() => {
<span class="fas fa-plus m-1"></span> Create New Archive
{/if}
</button>
{:else}
<span></span>
{/if}
<div class="flex flex-row items-center gap-2">

View File

@@ -280,7 +280,7 @@ let meetings_filtered = $derived.by(() => {
});
// --- Derived: grouped by room ---
// Rooms sorted by most-recent session desc; sessions within each room sorted asc.
// Rooms sorted by most-recent session desc; sessions within each room sorted desc.
let meetings_grouped = $derived.by<Map<string, MeetingReportEnriched[]>>(() => {
const groups = new Map<string, MeetingReportEnriched[]>();
for (const m of meetings_filtered) {
@@ -291,8 +291,8 @@ let meetings_grouped = $derived.by<Map<string, MeetingReportEnriched[]>>(() => {
for (const sessions of groups.values()) {
sessions.sort(
(a, b) =>
new Date(a.start_time).getTime() -
new Date(b.start_time).getTime()
new Date(b.start_time).getTime() -
new Date(a.start_time).getTime()
);
}
return new Map(
@@ -358,6 +358,41 @@ function is_room_open(room_name: string): boolean {
// --- URL Builder ---
let show_url_builder = $state(false);
let copied_participants_meeting_id = $state<string | null>(null);
let copied_participants_timeout: ReturnType<typeof setTimeout> | null = null;
function build_participant_copy_text(participants: MeetingParticipant[]): string {
return participants
.map((participant) =>
participant.role === 'moderator'
? `Mod: ${participant.displayName}`
: participant.displayName
)
.join('\n');
}
async function copy_participants(
meeting_id: string,
participants: MeetingParticipant[]
) {
const text = build_participant_copy_text(participants);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
copied_participants_meeting_id = meeting_id;
if (copied_participants_timeout) {
clearTimeout(copied_participants_timeout);
}
copied_participants_timeout = setTimeout(() => {
if (copied_participants_meeting_id === meeting_id) {
copied_participants_meeting_id = null;
}
}, 2000);
} catch (err) {
console.warn('Failed to copy participants to clipboard.', err);
}
}
// --- Export ---
function download_file(content: string, filename: string, mime: string) {
@@ -422,32 +457,36 @@ function export_json() {
<title>&AElig;: Jitsi Meeting Reports</title>
</svelte:head>
<div class="w-full max-w-5xl space-y-4 p-4">
<div class="bg-surface-50-950 text-surface-950 dark:bg-surface-950 dark:text-surface-50 min-h-screen w-full max-w-5xl space-y-4 p-4">
<!-- Page header: view toggle + 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>
<h1 class="!text-surface-950 dark:!text-surface-50 text-xl font-bold">
Jitsi Meeting Reports
</h1>
<div class="flex items-center gap-2">
<!-- View mode toggle -->
<div class="border-surface-200-800 flex overflow-hidden rounded-lg border text-sm">
<div class="border-surface-200-800 flex overflow-hidden rounded-lg border bg-surface-50-950 p-0.5 text-sm shadow-sm">
<button
type="button"
onclick={() => (group_by_room = true)}
title="Group sessions by room"
class="flex items-center gap-1.5 px-3 py-1.5 font-medium transition-colors duration-150 {group_by_room
? 'preset-filled-primary'
: 'opacity-60 hover:opacity-90 hover:bg-surface-100-900'}">
aria-pressed={group_by_room}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all duration-150 {group_by_room
? '!bg-primary-400 !text-white !shadow-sm ring-1 ring-primary-700'
: '!bg-transparent !text-surface-700 hover:!bg-surface-100-900 hover:!text-surface-950 dark:!text-surface-200 dark:hover:!text-surface-50'}">
<span class="fas fa-layer-group text-xs" aria-hidden="true"></span>
<span class="hidden sm:inline">By Room</span>
<span>By Room</span>
</button>
<button
type="button"
onclick={() => (group_by_room = false)}
title="Show all sessions as a flat list"
class="border-surface-200-800 flex items-center gap-1.5 border-l px-3 py-1.5 font-medium transition-colors duration-150 {!group_by_room
? 'preset-filled-primary'
: 'opacity-60 hover:opacity-90 hover:bg-surface-100-900'}">
aria-pressed={!group_by_room}
class="border-surface-200-800 flex items-center gap-1.5 rounded-md border-l px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all duration-150 {!group_by_room
? '!bg-primary-400 !text-white !shadow-sm ring-1 ring-primary-700'
: '!bg-transparent !text-surface-700 hover:!bg-surface-100-900 hover:!text-surface-950 dark:!text-surface-200 dark:hover:!text-surface-50'}">
<span class="fas fa-list text-xs" aria-hidden="true"></span>
<span class="hidden sm:inline">Flat List</span>
<span>Flat List</span>
</button>
</div>
<button
@@ -455,10 +494,11 @@ function export_json() {
onclick={export_csv}
disabled={meetings_filtered.length === 0}
title="Export filtered meetings as CSV"
class="btn btn-sm preset-tonal-primary disabled:opacity-40">
class="btn btn-sm preset-tonal-surface border-surface-200-800 border disabled:opacity-40">
<span class="fas fa-file-csv" aria-hidden="true"></span>
<span class="ml-1 hidden sm:inline">CSV</span>
</button>
{#if $ae_loc.edit_mode}
<button
type="button"
onclick={export_json}
@@ -468,6 +508,7 @@ function export_json() {
<span class="fas fa-file-code" aria-hidden="true"></span>
<span class="ml-1 hidden sm:inline">JSON</span>
</button>
{/if}
</div>
</div>
@@ -746,22 +787,28 @@ function export_json() {
<tr
class="bg-surface-100-900 border-surface-200-800 border-b">
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
class="text-left font-medium whitespace-nowrap opacity-60"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;"
>Date</th>
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
class="text-left font-medium whitespace-nowrap opacity-60"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;"
>Start</th>
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
class="text-left font-medium whitespace-nowrap opacity-60"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;"
>End</th>
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
class="text-left font-medium whitespace-nowrap opacity-60"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;"
>Duration</th>
<th
class="px-3 py-2 text-center font-medium whitespace-nowrap opacity-60"
class="text-center font-medium whitespace-nowrap opacity-60"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;"
title="Participant count (after staff exclusion)">#</th>
<th
class="px-3 py-2 text-left font-medium opacity-60"
class="text-left font-medium opacity-60"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;"
>Participants</th>
</tr>
</thead>
@@ -771,15 +818,17 @@ function export_json() {
{@const others = m.real_participants.filter((p) => p.role !== 'moderator')}
{@const all_names = m.real_participants.map((p) => `${p.displayName} (${p.role})`).join('\n')}
<tr
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
class="border-surface-200-800 bg-surface-50-900 text-surface-950 hover:bg-surface-100-900 dark:bg-surface-900 dark:text-surface-50 border-b transition-colors duration-200">
<td
class="px-3 py-2 whitespace-nowrap">
class="whitespace-nowrap"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;">
{new Date(
m.start_time
).toLocaleDateString()}
</td>
<td
class="px-3 py-2 whitespace-nowrap">
class="whitespace-nowrap"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;">
{new Date(
m.start_time
).toLocaleTimeString(
@@ -791,34 +840,57 @@ function export_json() {
)}
</td>
<td
class="px-3 py-2 whitespace-nowrap">
class="whitespace-nowrap"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;">
{compute_end_time(m.start_time, m.final_duration)}
</td>
<td
class="px-3 py-2 font-mono whitespace-nowrap">
class="font-mono whitespace-nowrap"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;">
{m.final_duration}
</td>
<td
class="px-3 py-2 text-center"
class="text-center"
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;"
title={all_names || 'No participants'}>
{m.real_participant_count}
</td>
<td class="px-3 py-2" title={all_names || 'No participants'}>
<td
title={all_names || 'No participants'}
style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem;">
{#if m.real_participant_count === 0}
<span class="opacity-40"></span>
{:else}
<div class="space-y-0.5 text-xs">
<div class="space-y-1 text-xs">
{#if mods.length > 0}
<div>
<div class="whitespace-normal break-words">
<span class="mr-1 opacity-40">Mod:</span>
<span class="font-semibold">{mods.map((p) => p.displayName).join(', ')}</span>
</div>
{/if}
{#if others.length > 0}
<div class="opacity-80">
{others.slice(0, 5).map((p) => p.displayName).join(', ')}{others.length > 5 ? ` +${others.length - 5} more` : ''}
<div class="whitespace-normal break-words opacity-80">
{others.map((p) => p.displayName).join(', ')}
</div>
{/if}
{#if $ae_loc.edit_mode}
<button
type="button"
onclick={() =>
copy_participants(
m.meeting_id,
m.real_participants
)}
class="inline-flex items-center gap-1 rounded border border-surface-200-800 bg-surface-100-900 px-2 py-1 text-xs font-medium transition-colors hover:bg-surface-200-800"
title="Copy participants to clipboard">
<span
class="fas fa-copy"
aria-hidden="true"></span>
{copied_participants_meeting_id === m.meeting_id
? 'Copied'
: 'Copy names'}
</button>
{/if}
</div>
{/if}
</td>
@@ -987,9 +1059,29 @@ function export_json() {
<!-- Final Participants (exclusion-applied) -->
<div>
<div
class="mb-2 text-xs tracking-wide uppercase opacity-40">
Final Participants ({meeting.real_participant_count})
<div class="mb-2 flex items-center justify-between gap-2">
<div
class="text-xs tracking-wide uppercase opacity-40">
Final Participants ({meeting.real_participant_count})
</div>
{#if $ae_loc.edit_mode}
<button
type="button"
onclick={() =>
copy_participants(
meeting.meeting_id,
meeting.real_participants
)}
class="inline-flex items-center gap-1 rounded border border-surface-200-800 bg-surface-100-900 px-2 py-1 text-xs font-medium transition-colors hover:bg-surface-200-800"
title="Copy participants to clipboard">
<span
class="fas fa-copy"
aria-hidden="true"></span>
{copied_participants_meeting_id === meeting.meeting_id
? 'Copied'
: 'Copy names'}
</button>
{/if}
</div>
{#if meeting.real_participants && meeting.real_participants.length > 0}
<table class="w-full text-sm">
@@ -997,20 +1089,24 @@ function export_json() {
<tr
class="border-surface-200-800 border-b">
<th
class="py-1 text-left font-medium opacity-60"
class="text-left font-medium opacity-60"
style="padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem;"
>Name</th>
<th
class="py-1 text-left font-medium opacity-60"
class="text-left font-medium opacity-60"
style="padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem;"
>Role</th>
</tr>
</thead>
<tbody>
{#each meeting.real_participants as participant (participant.displayName)}
<tr
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
<td class="py-1"
class="border-surface-200-800 bg-surface-50-900 text-surface-950 hover:bg-surface-100-900 dark:bg-surface-900 dark:text-surface-50 border-b transition-colors duration-200">
<td
style="padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem;"
>{participant.displayName}</td>
<td class="py-1"
<td
style="padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem;"
>{ae_util.to_title_case(
participant.role
)}</td>

View File

@@ -64,6 +64,13 @@ function copy_meeting_link() {
});
}
function build_jitsi_reports_href() {
const url = new URL($page.url);
url.pathname = '/idaa/jitsi_reports';
url.searchParams.delete('room');
return `${url.pathname}${url.search}${url.hash}`;
}
/**
* Creates a new activity log entry for a discrete event (e.g., raise hand).
*/
@@ -1238,15 +1245,24 @@ async function init_jitsi() {
already in a full tab. WHY: Novi iframes squish the layout and scrolling is unreliable.
Members need a way to escape to a proper full-tab context. -->
{#if $ae_loc.iframe || $ae_loc.edit_mode}
<div class="fixed bottom-0.5 left-8 z-20 print:hidden">
<div class="fixed bottom-0.5 left-8 z-20 flex gap-2 print:hidden">
<button
type="button"
onclick={() => (show_breakout_modal = true)}
class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-3 py-2 mb-2 text-sm shadow-md backdrop-blur-sm hover:bg-white"
class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-3 py-2 text-sm shadow-md backdrop-blur-sm hover:bg-white"
title="Open this meeting outside the Novi iframe">
<span class="fas fa-external-link-alt" aria-hidden="true"></span>
Open Externally
</button>
{#if $ae_loc.trusted_access}
<a
href={build_jitsi_reports_href()}
class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-3 py-2 text-sm shadow-md backdrop-blur-sm hover:bg-white"
title="Open Jitsi Reports">
<span class="fas fa-chart-bar" aria-hidden="true"></span>
Jitsi Reports
</a>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IDAA Novi Jitsi Reports iframe Example Template Page</title>
</head>
<body>
<!-- README: This is an example template page for embedding the IDAA Jitsi Reports iframe in the IDAA Novi site. Copy the code below to use it in your own page(s). -->
<!-- START: Copy below this point -->
<!-- IMPORTANT: The <p> and <script> elements below are for using in an iframe in the IDAA Novi site. -->
<!-- IDAA OSIT iframe container element for Novi - Archives iframe -->
<p>
<iframe
width="100%"
height="750"
id="ae_idaa_jitsi_reports_iframe"
src=""
style="min-height: 600px; max-height: 100%"
class="ae_idaa_iframe"
></iframe>
</p>
<!-- IDAA and Novi specific JavaScript to get current Novi user info and load Jitsi Reports iframe -->
<script>
// NOTE: The Novi UUID for the current user — injected server-side by Novi.
// This is the only identity value passed to the iframe. Identity verification
// is handled securely by the OSIT server — do not add email or name here.
let novi_customer_uid = '<%=Novi.User.CustomerUniqueId%>';
console.log(`Novi's Current User's ID: ${novi_customer_uid}`);
// WARNING: Do *not* use relative paths here. They must be direct to the site OSIT is hosting for IDAA.
let idaa_osit_ae_api_root_url = 'https://dev-idaa.oneskyit.com/idaa/jitsi_reports'; // NOTE: DO NOT CHANGE THIS VALUE
// Example URLs: 'https://sk-idaa.oneskyit.com/idaa/jitsi_reports' OR 'https://dev-idaa.oneskyit.com/idaa/jitsi_reports'
// WARNING: Do *not* change this value. It is required for access control to the IDAA AE API.
let idaa_osit_ae_site_key = 'restricted-access'; // DO NOT CHANGE THIS VALUE
let idaa_ae_params = new URLSearchParams(document.location.search);
let idaa_ae_slct_archive_id = idaa_ae_params.get('archive_id');
let idaa_ae_iframe_height = null;
let idaa_ae_iframe_element = document.getElementById('ae_idaa_jitsi_reports_iframe');
if (idaa_ae_slct_archive_id) {
idaa_ae_iframe_element.src = `${idaa_osit_ae_api_root_url}/${idaa_ae_slct_archive_id}?uuid=${novi_customer_uid}&iframe=true&key=${idaa_osit_ae_site_key}`;
} else {
idaa_ae_iframe_element.src = `${idaa_osit_ae_api_root_url}?uuid=${novi_customer_uid}&iframe=true&key=${idaa_osit_ae_site_key}`;
}
// NOTE: This listener handles messages sent from the IDAA iframe back to this parent page.
// It adjusts the iframe height dynamically, scrolls the page when navigation occurs inside
// the iframe, and keeps the browser URL in sync with navigation inside the iframe.
window.addEventListener('message', function (event) {
if (event.data) {
if (event.data.iframe_height) {
idaa_ae_iframe_height = event.data.iframe_height;
let idaa_ae_iframe_element =
document.getElementById('ae_idaa_jitsi_reports_iframe');
idaa_ae_iframe_element.style.height = `${idaa_ae_iframe_height + 50}px`;
}
if (event.data.scroll_to !== undefined) {
console.log(`Got scroll_to: ${event.data.scroll_to}`);
let idaa_ae_iframe_element = document.getElementById('ae_idaa_jitsi_reports_iframe');
if (idaa_ae_iframe_element) {
// NOTE: Scroll to the top of the iframe element, not the absolute page top.
// The iframe is embedded below Novi's own header and navigation, so
// scrolling to (0, 0) would show the Novi site header instead of the iframe.
let idaa_ae_iframe_top = window.pageYOffset + idaa_ae_iframe_element.getBoundingClientRect().top;
console.log(`Scrolling to iframe top: ${idaa_ae_iframe_top}px`);
window.scrollTo({
top: Math.max(0, idaa_ae_iframe_top - 20),
left: 0,
behavior: 'smooth'
});
} else {
console.warn(`Element with ID "ae_idaa_jitsi_reports_iframe" not found.`);
}
}
/*
const url = new URL(location);
// Check if archive_id is defined in the message
if (event.data.archive_id !== undefined) {
console.log(`Got AE Jitsi Report ID: ${event.data.archive_id}`);
idaa_ae_slct_archive_id = event.data.archive_id;
if (event.data.archive_id) {
url.searchParams.set('archive_id', event.data.archive_id);
} else {
url.searchParams.delete('archive_id');
}
history.pushState({}, '', url);
}
// Check if archive_content_id is defined in the message
if (event.data.archive_content_id !== undefined) {
console.log(`Got AE Jitsi Report Content ID: ${event.data.archive_content_id}`);
idaa_ae_slct_archive_content_id = event.data.archive_content_id;
if (event.data.archive_content_id) {
url.searchParams.set(
'archive_content_id',
event.data.archive_content_id
);
} else {
url.searchParams.delete('archive_content_id');
}
history.pushState({}, '', url);
}
*/
} else {
console.log(`No data in message? ${event}`);
}
});
</script>
<!-- STOP: Do not copy below this point -->
</body>
</html>