Compare commits
22 Commits
392217e66c
...
3553809f27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3553809f27 | ||
|
|
6e700e7b4d | ||
|
|
d7b4d8c37c | ||
|
|
d9f704fe25 | ||
|
|
af74b52481 | ||
|
|
730eea4ce7 | ||
|
|
09f1a6ee57 | ||
|
|
64c3afe564 | ||
|
|
54dfd734e6 | ||
|
|
c7ebeebe29 | ||
|
|
c71fc65be9 | ||
|
|
8b7597906f | ||
|
|
c289268550 | ||
|
|
09a5178b89 | ||
|
|
e64252b839 | ||
|
|
25e35f6f96 | ||
|
|
74bc3b3625 | ||
|
|
cd868460fe | ||
|
|
6ebf4f125d | ||
|
|
0ae8cf63d7 | ||
|
|
d32312653d | ||
|
|
f5155eba50 |
@@ -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>`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -299,6 +299,7 @@ export const properties_to_save = [
|
||||
'archive_id',
|
||||
'archive_content_type',
|
||||
'name',
|
||||
'code',
|
||||
'description',
|
||||
'content_html',
|
||||
'content_json',
|
||||
|
||||
@@ -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]`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>Æ: 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
126
static/idaa_novi_iframe_jitsi_reports.html
Normal file
126
static/idaa_novi_iframe_jitsi_reports.html
Normal 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>
|
||||
Reference in New Issue
Block a user