feat(pres_mgmt): File Downloads report with clean filename presets
New report at Pres Mgmt > Reports > File Downloads. Groups all event files by session and presenter, with 10 filename format presets (original, session code/date/name combos, presenter variants). Per-file Download button and Copy Link button using hosted_file endpoint for ?key= auth support. Also fixes direct download links in element_manage_event_file_li and the hosted_files download button — event_file endpoint does not yet propagate ?key= auth internally, so all direct links now use hosted_file endpoint which supports it today. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,26 @@ The primary way to retrieve data.
|
||||
* **Endpoint:** `POST /v3/crud/{obj_type}/search`
|
||||
* **Security:** Automatically filters results to only show records belonging to your `x-account-id`. If no account context is provided, it will return **0 records** for private objects.
|
||||
|
||||
#### Sorting with `order_by_li`
|
||||
|
||||
Pass a JSON object as the `order_by_li` query parameter to sort results:
|
||||
|
||||
```ts
|
||||
// ?order_by_li={"filename":"ASC","created_on":"DESC"}
|
||||
const params = new URLSearchParams({
|
||||
order_by_li: JSON.stringify({ filename: 'ASC', created_on: 'DESC' })
|
||||
});
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **`order_by_li` only accepts columns from the raw base table** — not view-only join columns.
|
||||
>
|
||||
> Some object types (e.g. `event_file`) have enriched views that JOIN other tables to expose convenience fields like `event_presenter_family_name`. These are available in search results when using `?view=alt`, but they **cannot** be used in `order_by_li`. Attempting to sort by them silently drops those sort keys (the query proceeds without them).
|
||||
>
|
||||
> If you need to sort by a joined field, sort client-side on the returned list.
|
||||
>
|
||||
> **Columns safe to sort on for `event_file`:** any field in the `event_file` table itself — `filename`, `title`, `extension`, `created_on`, `updated_on`, `sort`, `enable`, etc.
|
||||
|
||||
### C. POST Create / PATCH Update
|
||||
Modify data in the system.
|
||||
* **Endpoints:**
|
||||
@@ -281,6 +301,47 @@ Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file
|
||||
* `hosted_file_size` (string - in bytes)
|
||||
2. **Nested Hosted File Object:** A full `hosted_file` object will be nested under the `hosted_file` key. This object (`Hosted_File_Base` model) will contain all its standard fields, including `id` (random string ID), `hash_sha256`, `content_type`, `size`, etc.
|
||||
|
||||
### Direct Download Links (Shareable / External)
|
||||
|
||||
Event files can be downloaded without standard auth headers using one of two bypass mechanisms. This is useful for generating shareable links for staff or external recipients.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/v3/action/event_file/{event_file_id}/download`
|
||||
|
||||
The endpoint also accepts `hosted_file_id` or `archive_content_id` directly — it resolves the chain automatically.
|
||||
|
||||
#### Auth bypass options
|
||||
|
||||
| Query param | Value | When to use |
|
||||
|---|---|---|
|
||||
| `?key=<account_id_random>` | Any valid account random ID | Staff sharing within a known account context |
|
||||
| `?site_key=<site_access_key>` | The site's `access_key` value | Public or semi-public distribution tied to a specific site |
|
||||
|
||||
Either param replaces the need for `x-aether-api-key` / `x-account-id` headers, so the URL is self-contained and works in a plain browser tab or `<a href>` link.
|
||||
|
||||
#### Optional params
|
||||
|
||||
| Query param | Description |
|
||||
|---|---|
|
||||
| `filename` | Override the download filename (min 4 chars). Useful for giving files clean display names. |
|
||||
|
||||
#### Building a shareable link
|
||||
|
||||
```ts
|
||||
// Build a self-contained download URL for staff/external use
|
||||
function makeDownloadUrl(eventFileId: string, accountId: string, displayName?: string): string {
|
||||
const base = `https://dev-api.oneskyit.com/v3/action/event_file/${eventFileId}/download`;
|
||||
const params = new URLSearchParams({ key: accountId });
|
||||
if (displayName) params.set('filename', displayName);
|
||||
return `${base}?${params}`;
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint supports byte-range requests (`Range` header), so it works correctly for in-browser media streaming as well as direct file downloads.
|
||||
|
||||
> [!NOTE]
|
||||
> The `?key=` bypass verifies only that the account ID exists — it does not confirm the file belongs to that account. It is appropriate for internal staff tools. For publicly distributed links, prefer `?site_key=` which ties access to a specific site's configured key.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hosted File Actions: Convert & Clip (Frontend Notes)
|
||||
@@ -301,18 +362,16 @@ These helper endpoints let the frontend request small server-side transformation
|
||||
- Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`)
|
||||
- Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool)
|
||||
- Auth: standard V3 headers
|
||||
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. Returns 400 on failure.
|
||||
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
|
||||
- Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize.
|
||||
- For longer-running clips you can schedule the job in the background by adding `?background=true`. When scheduled the API returns `202 Accepted` and the clip runs asynchronously on the server; check the returned `hosted_file` record later via the standard V3 `hosted_file` endpoints.
|
||||
- Returns 400 on synchronous failure; returns 202 when scheduled successfully.
|
||||
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
|
||||
- Defaults to stream-copying (fast); set `reencode=true` to force H.264 or `scale_down=true` to resize.
|
||||
- Add `?background=true` to schedule the clip asynchronously — returns `202 Accepted` immediately; poll the `hosted_file` record for completion.
|
||||
- Returns 400 on synchronous failure; 202 when scheduled successfully.
|
||||
|
||||
Frontend guidance:
|
||||
|
||||
- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
|
||||
- After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file.
|
||||
- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead.
|
||||
- These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead.
|
||||
- Prefer `?background=true` for large inputs to avoid request timeouts. For heavy or batch workloads use a queued job pattern instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -673,19 +732,19 @@ Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether
|
||||
|---|---|---|
|
||||
| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member |
|
||||
| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry |
|
||||
| `503` | Novi unreachable or Novi 5xx error | Auto-retry once after 3s; if retry also fails, surface as `'api_error'` |
|
||||
| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry |
|
||||
|
||||
### Migration from direct Novi call — ✅ Complete (2026-05-19)
|
||||
### Migration from direct Novi call
|
||||
|
||||
`+layout.svelte:verify_novi_uuid()` now calls this endpoint instead of Novi directly. Response code mapping (for reference):
|
||||
The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping:
|
||||
|
||||
| Direct Novi result | This endpoint returns | Frontend behavior |
|
||||
| Direct Novi result | This endpoint returns | Frontend state |
|
||||
|---|---|---|
|
||||
| `200` with identity data | `200` | `verified` |
|
||||
| `200` with no identity data | `404` | `denied` |
|
||||
| `404` | `404` | `denied` |
|
||||
| `429` | `429` | Auto-retry after 10s; `'rate_limited'` if retry fails |
|
||||
| Network error / Novi 5xx | `503` | Auto-retry after 3s; `'api_error'` if retry fails |
|
||||
| `429` | `429` | `'rate_limited'` |
|
||||
| Network error / Novi 5xx | `503` | `'api_error'` |
|
||||
|
||||
### Caching
|
||||
|
||||
|
||||
@@ -186,16 +186,13 @@ let is_url_file = $derived.by(() => {
|
||||
|
||||
let direct_download_url = $derived.by(() => {
|
||||
if (!show_direct_download || !hosted_file_obj) return '';
|
||||
// IMPORTANT: For Direct Link Mode, we MUST use the V3 Action endpoint to support Random String IDs.
|
||||
// Legacy endpoints often expect integer IDs and will return 404 for string IDs.
|
||||
// Always use the hosted_file endpoint — it supports ?key= auth and resolves event_file
|
||||
// IDs automatically via Redis. The event_file endpoint's ?key= support is not yet deployed.
|
||||
const file_id =
|
||||
hosted_file_obj.event_file_id ||
|
||||
hosted_file_obj.hosted_file_id ||
|
||||
hosted_file_obj.event_file_id ||
|
||||
hosted_file_id;
|
||||
const obj_type_path = hosted_file_obj.event_file_id
|
||||
? 'event_file'
|
||||
: 'hosted_file';
|
||||
return `${$ae_api.base_url}/v3/action/${obj_type_path}/${file_id}/download?filename=${ae_util.clean_filename(final_filename)}&key=${$ae_api.account_id}`;
|
||||
return `${$ae_api.base_url}/v3/action/hosted_file/${file_id}/download?filename=${ae_util.clean_filename(final_filename)}&key=${$ae_api.account_id}`;
|
||||
});
|
||||
|
||||
async function handle_click() {
|
||||
|
||||
@@ -317,7 +317,7 @@ async function handle_convert_pdf_to_image(event_file_obj: key_val) {
|
||||
</span>
|
||||
<MyClipboard
|
||||
value={encodeURI(
|
||||
`${$ae_api.base_url}/v3/action/event_file/${event_file_obj?.event_file_id}/download?filename=${ae_util.clean_filename(event_file_obj?.filename)}&key=${$ae_api.account_id}`
|
||||
`${$ae_api.base_url}/v3/action/hosted_file/${event_file_obj?.hosted_file_id}/download?filename=${ae_util.clean_filename(event_file_obj?.filename)}&key=${$ae_api.account_id}`
|
||||
)}
|
||||
btn_text="Copy Original"
|
||||
btn_title="Copy the direct download link to the clipboard."
|
||||
@@ -326,10 +326,10 @@ async function handle_convert_pdf_to_image(event_file_obj: key_val) {
|
||||
|
||||
<MyClipboard
|
||||
value={encodeURI(
|
||||
`${$ae_api.base_url}/v3/action/event_file/${event_file_obj?.event_file_id}/download?filename=${event_file_obj?.event_session_code}-${ae_util.clean_filename(event_file_obj?.event_session_name).substring(0, 20)}-${ae_util.clean_filename(event_file_obj?.event_presenter_full_name)}.${event_file_obj?.extension}&key=${$ae_api.account_id}`
|
||||
`${$ae_api.base_url}/v3/action/hosted_file/${event_file_obj?.hosted_file_id}/download?filename=${ae_util.clean_filename(event_file_obj?.event_session_code ?? '')}_${ae_util.clean_filename(event_file_obj?.event_presentation_name ?? event_file_obj?.event_session_name ?? '').substring(0, 30)}_${ae_util.clean_filename(event_file_obj?.event_presenter_full_name ?? '')}.${event_file_obj?.extension}&key=${$ae_api.account_id}`
|
||||
)}
|
||||
btn_text="Copy Renamed"
|
||||
btn_title="Copy the renamed download link to the clipboard."
|
||||
btn_title="Copy the renamed download link to the clipboard. Format: [session-code]_[presentation-name]_[presenter-name].[ext]"
|
||||
btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500"
|
||||
></MyClipboard>
|
||||
</div>
|
||||
|
||||
@@ -244,6 +244,7 @@ export interface PresMgmtSessState {
|
||||
recent_files: string | null;
|
||||
presenters_agree: string | null;
|
||||
presenters_biography: string | null;
|
||||
file_downloads: string | null;
|
||||
};
|
||||
rpt__session_no_files: boolean;
|
||||
rpt__session_poc_agree: boolean;
|
||||
@@ -414,7 +415,8 @@ export const pres_mgmt_sess_defaults: PresMgmtSessState = {
|
||||
status_rpt: {
|
||||
recent_files: null,
|
||||
presenters_agree: null,
|
||||
presenters_biography: null
|
||||
presenters_biography: null,
|
||||
file_downloads: null
|
||||
},
|
||||
rpt__session_no_files: true,
|
||||
rpt__session_poc_agree: false,
|
||||
|
||||
@@ -39,9 +39,11 @@ import Event_reports_page_menu from './event_reports_page_menu.svelte';
|
||||
import Reports_sessions from './reports_sessions.svelte';
|
||||
import Reports_presenters from './reports_presenters.svelte';
|
||||
import Reports_files from './reports_files.svelte';
|
||||
import Reports_file_downloads from './reports_file_downloads.svelte';
|
||||
import {
|
||||
Check,
|
||||
ClipboardList,
|
||||
Download,
|
||||
File,
|
||||
IdCard,
|
||||
ListChecks,
|
||||
@@ -90,7 +92,8 @@ let lq__event_obj = $derived(
|
||||
let ae_triggers: key_val = $state({
|
||||
rpt__event_files: true,
|
||||
rpt__event_sessions: true,
|
||||
rpt__event_presenters: true
|
||||
rpt__event_presenters: true,
|
||||
rpt__file_downloads: true
|
||||
});
|
||||
|
||||
let url_hash: string = $state($page?.url?.hash);
|
||||
@@ -311,6 +314,25 @@ $effect(() => {
|
||||
<File size="1em" class="m-1" />
|
||||
Event Files
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!$ae_loc.trusted_access}
|
||||
onclick={() => {
|
||||
ae_triggers.rpt__file_downloads = true;
|
||||
pres_mgmt_loc.current.show_report = 'file_downloads';
|
||||
}}
|
||||
class:hidden={$lq__event_obj?.mod_pres_mgmt_json
|
||||
?.hide__report_kv.file_downloads}
|
||||
class="btn btn-sm preset-tonal-success border-success-500 hover:preset-filled-success-500 m-1 border transition-all"
|
||||
title="File Downloads — download files grouped by session and presenter with clean filename options.">
|
||||
{#if pres_mgmt_loc.current.show_report == 'file_downloads' && $events_sess.pres_mgmt.status_rpt.file_downloads == 'loading'}
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
{:else}
|
||||
<Download size="1em" class="m-1" />
|
||||
{/if}
|
||||
File Downloads
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -362,4 +384,14 @@ $effect(() => {
|
||||
hide_session_code={pres_mgmt_loc.current.hide__session_code}
|
||||
{log_lvl} />
|
||||
{/if}
|
||||
|
||||
<!-- File Downloads report — grouped by session/presenter with clean filename options -->
|
||||
{#if pres_mgmt_loc.current.show_report == 'file_downloads'}
|
||||
<Reports_file_downloads
|
||||
{lq__event_obj}
|
||||
rpt__limit={pres_mgmt_loc.current.qry_max}
|
||||
bind:qry__status={$events_sess.pres_mgmt.status_qry__search}
|
||||
bind:qry__trigger={ae_triggers.rpt__file_downloads}
|
||||
{log_lvl} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
log_lvl?: number;
|
||||
lq__event_obj: any;
|
||||
event_file_obj_li?: any[];
|
||||
qry__status?: string;
|
||||
qry__count?: number;
|
||||
qry__trigger?: boolean;
|
||||
rpt__limit?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
log_lvl = $bindable(0),
|
||||
lq__event_obj,
|
||||
event_file_obj_li = $bindable([]),
|
||||
qry__status = $bindable(''),
|
||||
qry__count = $bindable(0),
|
||||
qry__trigger = $bindable(true),
|
||||
rpt__limit = $bindable(500)
|
||||
}: Props = $props();
|
||||
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_slct, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Download,
|
||||
FileText,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
User,
|
||||
Users
|
||||
} from '@lucide/svelte';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filename helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Strip OS-unsafe chars AND periods (no periods in filename stems).
|
||||
function clean_part(s: any, max?: number): string {
|
||||
if (!s) return '';
|
||||
const out = ae_util
|
||||
.clean_filename(String(s))
|
||||
.replace(/\./g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '');
|
||||
return max ? out.substring(0, max) : out;
|
||||
}
|
||||
|
||||
function date_part(dt: string | null | undefined): string {
|
||||
return dt ? String(dt).substring(0, 10) : ''; // 'YYYY-MM-DD'
|
||||
}
|
||||
|
||||
function time_part(dt: string | null | undefined): string {
|
||||
if (!dt) return '';
|
||||
return String(dt).substring(11, 16).replace(':', '-'); // 'HH-MM'
|
||||
}
|
||||
|
||||
type FormatKey =
|
||||
| 'original'
|
||||
| 'session_code'
|
||||
| 'session_code_name'
|
||||
| 'session_code_date'
|
||||
| 'session_code_date_time'
|
||||
| 'session_code_date_name'
|
||||
| 'session_presenter'
|
||||
| 'session_date_presenter'
|
||||
| 'session_presentation_presenter'
|
||||
| 'session_date_presentation_presenter';
|
||||
|
||||
const FORMAT_PRESETS: Record<FormatKey, { label: string; group: string }> = {
|
||||
original: { label: 'Original filename', group: 'Universal' },
|
||||
session_code: { label: 'Session Code', group: 'Session' },
|
||||
session_code_name: { label: 'Session Code + Name', group: 'Session' },
|
||||
session_code_date: { label: 'Session Code + Date', group: 'Session' },
|
||||
session_code_date_time: { label: 'Session Code + Date + Time', group: 'Session' },
|
||||
session_code_date_name: { label: 'Session Code + Date + Name', group: 'Session' },
|
||||
session_presenter: { label: 'Session Code + Presenter (Family, Given)', group: 'Presenter' },
|
||||
session_date_presenter: { label: 'Session Code + Date + Presenter', group: 'Presenter' },
|
||||
session_presentation_presenter: { label: 'Session Code + Presentation + Presenter', group: 'Presenter' },
|
||||
session_date_presentation_presenter: { label: 'Session Code + Date + Presentation + Presenter (Full)', group: 'Presenter' }
|
||||
};
|
||||
|
||||
// Separate group labels for the <optgroup> elements
|
||||
const FORMAT_GROUPS: Record<string, FormatKey[]> = {
|
||||
Universal: ['original'],
|
||||
Session: ['session_code', 'session_code_name', 'session_code_date', 'session_code_date_time', 'session_code_date_name'],
|
||||
Presenter: ['session_presenter', 'session_date_presenter', 'session_presentation_presenter', 'session_date_presentation_presenter']
|
||||
};
|
||||
|
||||
let selected_format = $state<FormatKey>('session_code_name');
|
||||
|
||||
function build_filename(file: any, fmt: FormatKey): string {
|
||||
const ext = file.extension ?? 'file';
|
||||
const session_code = clean_part(file.event_session_code);
|
||||
const session_name = clean_part(file.event_session_name, 30);
|
||||
const session_date = date_part(file.event_session_start_datetime);
|
||||
const session_time = time_part(file.event_session_start_datetime);
|
||||
const pres_name = clean_part(file.event_presentation_name ?? file.event_session_name, 25);
|
||||
const fam = clean_part(file.event_presenter_family_name);
|
||||
const given = clean_part(file.event_presenter_given_name);
|
||||
|
||||
if (fmt === 'original') return file.filename ?? `file.${ext}`;
|
||||
|
||||
let parts: string[];
|
||||
switch (fmt) {
|
||||
case 'session_code': parts = [session_code]; break;
|
||||
case 'session_code_name': parts = [session_code, session_name]; break;
|
||||
case 'session_code_date': parts = [session_code, session_date]; break;
|
||||
case 'session_code_date_time': parts = [session_code, session_date, session_time]; break;
|
||||
case 'session_code_date_name': parts = [session_code, session_date, session_name]; break;
|
||||
case 'session_presenter': parts = [session_code, fam, given]; break;
|
||||
case 'session_date_presenter': parts = [session_code, session_date, fam, given]; break;
|
||||
case 'session_presentation_presenter': parts = [session_code, pres_name, fam, given]; break;
|
||||
case 'session_date_presentation_presenter': parts = [session_code, session_date, pres_name, fam, given]; break;
|
||||
default: return file.filename ?? `file.${ext}`;
|
||||
}
|
||||
|
||||
const stem = parts.filter(Boolean).join('_').replace(/_+/g, '_').replace(/^_|_$/, '');
|
||||
return `${stem}.${ext}`;
|
||||
}
|
||||
|
||||
function build_download_url(file: any, fmt: FormatKey): string {
|
||||
const fname = build_filename(file, fmt);
|
||||
// Using hosted_file endpoint — event_file ?key= auth not yet deployed on backend.
|
||||
// hosted_file resolves event_file IDs automatically and supports ?key= today.
|
||||
return encodeURI(
|
||||
`${$ae_api.base_url}/v3/action/hosted_file/${file.hosted_file_id}/download?filename=${ae_util.clean_filename(fname)}&key=${$ae_api.account_id}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PresenterGroup = {
|
||||
presenter_id: string;
|
||||
presenter_full_name: string;
|
||||
presenter_family_name: string;
|
||||
presenter_given_name: string;
|
||||
presentation_name: string | null;
|
||||
files: any[];
|
||||
};
|
||||
|
||||
type SessionGroup = {
|
||||
session_id: string;
|
||||
session_code: string;
|
||||
session_name: string;
|
||||
session_start_datetime: string | null;
|
||||
session_files: any[]; // for_type = 'event_session'
|
||||
presenter_groups: PresenterGroup[]; // for_type = 'event_presenter'
|
||||
_presenter_map: Record<string, PresenterGroup>;
|
||||
};
|
||||
|
||||
let session_groups = $derived.by((): SessionGroup[] => {
|
||||
const map: Record<string, SessionGroup> = {};
|
||||
|
||||
for (const file of event_file_obj_li ?? []) {
|
||||
if (file.for_type !== 'event_session' && file.for_type !== 'event_presenter') continue;
|
||||
|
||||
const sid = file.event_session_id ?? '__no_session__';
|
||||
if (!map[sid]) {
|
||||
map[sid] = {
|
||||
session_id: sid,
|
||||
session_code: file.event_session_code ?? '',
|
||||
session_name: file.event_session_name ?? '',
|
||||
session_start_datetime: file.event_session_start_datetime ?? null,
|
||||
session_files: [],
|
||||
presenter_groups: [],
|
||||
_presenter_map: {}
|
||||
};
|
||||
}
|
||||
const sg = map[sid];
|
||||
|
||||
if (file.for_type === 'event_session') {
|
||||
sg.session_files.push(file);
|
||||
} else {
|
||||
const pid = file.event_presenter_id ?? '__unknown__';
|
||||
if (!sg._presenter_map[pid]) {
|
||||
const pg: PresenterGroup = {
|
||||
presenter_id: pid,
|
||||
presenter_full_name: file.event_presenter_full_name ?? '',
|
||||
presenter_family_name: file.event_presenter_family_name ?? '',
|
||||
presenter_given_name: file.event_presenter_given_name ?? '',
|
||||
presentation_name: file.event_presentation_name ?? null,
|
||||
files: []
|
||||
};
|
||||
sg._presenter_map[pid] = pg;
|
||||
sg.presenter_groups.push(pg);
|
||||
}
|
||||
sg._presenter_map[pid].files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = Object.values(map).sort((a, b) => {
|
||||
if (a.session_start_datetime && b.session_start_datetime)
|
||||
return a.session_start_datetime.localeCompare(b.session_start_datetime);
|
||||
return a.session_name.localeCompare(b.session_name);
|
||||
});
|
||||
|
||||
// Sort presenter groups within each session client-side (family name, then given name).
|
||||
// This can't be pushed to the API order_by_li — those are view-only joined columns
|
||||
// that trigger a backend "account_id" WHERE ambiguity when used in ORDER BY.
|
||||
for (const sg of sorted) {
|
||||
sg.presenter_groups.sort((a, b) => {
|
||||
const fam = a.presenter_family_name.localeCompare(b.presenter_family_name);
|
||||
if (fam !== 0) return fam;
|
||||
return a.presenter_given_name.localeCompare(b.presenter_given_name);
|
||||
});
|
||||
}
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
let total_session_files = $derived(session_groups.reduce((n, sg) => n + sg.session_files.length, 0));
|
||||
let total_presenter_files = $derived(session_groups.reduce((n, sg) => sg.presenter_groups.reduce((m, pg) => m + pg.files.length, 0) + n, 0));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$effect(() => {
|
||||
if (qry__trigger) {
|
||||
qry__trigger = false;
|
||||
handle_qry();
|
||||
}
|
||||
});
|
||||
|
||||
async function handle_qry() {
|
||||
qry__status = 'loading';
|
||||
qry__count = 0;
|
||||
$events_sess.pres_mgmt.status_qry__search = 'loading';
|
||||
$events_sess.pres_mgmt.status_rpt.file_downloads = 'loading';
|
||||
event_file_obj_li = [];
|
||||
|
||||
try {
|
||||
// ORDER BY only uses raw event_file table columns + session-join columns.
|
||||
// Presenter-level columns (event_presenter_family_name etc.) are view-only joined
|
||||
// fields — per API guide §3B they are silently dropped from ORDER BY.
|
||||
// Sort presenter groups client-side for predictable ordering.
|
||||
const results = await events_func.qry__event_file({
|
||||
api_cfg: $ae_api,
|
||||
event_id: $events_slct.event_id,
|
||||
enabled: 'enabled',
|
||||
hidden: 'not_hidden',
|
||||
view: 'alt', // required for joined fields: session code/name, presenter name, presentation name
|
||||
limit: rpt__limit,
|
||||
order_by_li: {
|
||||
event_session_start_datetime: 'ASC',
|
||||
event_session_name: 'ASC',
|
||||
priority: 'DESC',
|
||||
sort: 'DESC',
|
||||
created_on: 'DESC'
|
||||
},
|
||||
log_lvl
|
||||
}) ?? [];
|
||||
|
||||
event_file_obj_li = results;
|
||||
qry__count = results.length;
|
||||
qry__status = 'done';
|
||||
} catch {
|
||||
qry__status = 'error';
|
||||
} finally {
|
||||
$events_sess.pres_mgmt.status_qry__search = 'done';
|
||||
$events_sess.pres_mgmt.status_rpt.file_downloads = 'done';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Header / controls -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<header class="flex w-full flex-col gap-2">
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="h4 flex flex-row items-center gap-1">
|
||||
{#if qry__status === 'loading'}
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
{:else}
|
||||
<Download size="1em" />
|
||||
{/if}
|
||||
File Downloads
|
||||
{#if qry__count}
|
||||
<span class="badge preset-tonal-surface text-sm">{qry__count} files</span>
|
||||
{/if}
|
||||
<span class="text-sm font-normal opacity-60">
|
||||
({total_session_files} session / {total_presenter_files} presenter)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-tertiary border-tertiary-500/30 border"
|
||||
title="Re-fetch files from the API"
|
||||
onclick={() => { qry__trigger = true; }}>
|
||||
<RefreshCw size="1em" class="mr-1" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Format selector -->
|
||||
<div class="flex flex-row flex-wrap items-center gap-2">
|
||||
<label class="flex flex-row items-center gap-2 text-sm font-semibold">
|
||||
<ArrowUpDown size="1em" />
|
||||
Download filename format:
|
||||
<select
|
||||
class="select ae_btn_info w-auto max-w-xs text-sm"
|
||||
bind:value={selected_format}>
|
||||
{#each Object.entries(FORMAT_GROUPS) as [group_label, keys] (group_label)}
|
||||
<optgroup label={group_label}>
|
||||
{#each keys as key (key)}
|
||||
<option value={key}>{FORMAT_PRESETS[key].label}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<span class="text-surface-500 text-xs italic">
|
||||
Applied to all download buttons below. "Copy Link" copies the same URL.
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Results -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
|
||||
{#if qry__status === 'loading'}
|
||||
<div class="flex items-center gap-2 p-4">
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
<span>Loading files…</span>
|
||||
</div>
|
||||
{:else if qry__status === 'error'}
|
||||
<div class="preset-tonal-error rounded-md p-4">
|
||||
Failed to load files. Check your connection and try refreshing.
|
||||
</div>
|
||||
{:else if session_groups.length === 0 && qry__status === 'done'}
|
||||
<p class="text-surface-500 p-4 text-center italic">
|
||||
No session or presenter files found for this event.
|
||||
</p>
|
||||
{:else}
|
||||
{#each session_groups as sg (sg.session_id)}
|
||||
{@const has_any = sg.session_files.length > 0 || sg.presenter_groups.length > 0}
|
||||
{#if has_any}
|
||||
<!-- Session card -->
|
||||
<section class="border-surface-300-700 bg-surface-50-900 my-2 rounded-lg border">
|
||||
<!-- Session header -->
|
||||
<div class="bg-surface-200-800 flex flex-row flex-wrap items-baseline gap-2 rounded-t-lg px-3 py-2">
|
||||
{#if sg.session_code}
|
||||
<span class="badge preset-tonal-primary font-mono text-xs font-bold">{sg.session_code}</span>
|
||||
{/if}
|
||||
<span class="font-semibold">{sg.session_name || '— session name not set —'}</span>
|
||||
{#if sg.session_start_datetime}
|
||||
<span class="text-surface-500 text-xs">
|
||||
{ae_util.iso_datetime_formatter(sg.session_start_datetime, 'date_iso')}
|
||||
{ae_util.iso_datetime_formatter(sg.session_start_datetime, 'time_12_short_no_leading')}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-surface-400 ml-auto text-xs">
|
||||
{sg.session_files.length} session file{sg.session_files.length !== 1 ? 's' : ''},
|
||||
{sg.presenter_groups.reduce((n, pg) => n + pg.files.length, 0)} presenter file{sg.presenter_groups.reduce((n, pg) => n + pg.files.length, 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-surface-200-800 divide-y px-2 py-1">
|
||||
|
||||
<!-- ---- Session-level files ---- -->
|
||||
{#if sg.session_files.length > 0}
|
||||
<div class="py-2">
|
||||
<h4 class="mb-1 flex items-center gap-1 text-xs font-bold tracking-wider opacity-60">
|
||||
<FileText size="0.9em" />
|
||||
Session Files ({sg.session_files.length})
|
||||
</h4>
|
||||
<table class="w-full table-auto text-sm">
|
||||
<tbody>
|
||||
{#each sg.session_files as file (file.event_file_id)}
|
||||
{@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)}
|
||||
{@const computed_name = build_filename(file, selected_format)}
|
||||
{@const dl_url = build_download_url(file, selected_format)}
|
||||
<tr class="hover:bg-surface-100-900 border-surface-100-900 border-t transition-colors">
|
||||
<td class="py-1 pr-2">
|
||||
<span class="flex items-center gap-1 text-xs opacity-50" title={file.filename}>
|
||||
<ExtIcon size="0.9em" />
|
||||
{ae_util.shorten_filename({ filename: file.filename, max_length: 30 })}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-1">
|
||||
<span class="font-mono text-xs opacity-70" title="Filename that will be used for download">
|
||||
{computed_name}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-1 py-1 text-right text-xs opacity-50 whitespace-nowrap">
|
||||
{ae_util.format_bytes(file.file_size)}
|
||||
</td>
|
||||
<td class="pl-2 py-1 whitespace-nowrap">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<a
|
||||
href={dl_url}
|
||||
download={computed_name}
|
||||
class="btn btn-xs preset-tonal-primary border-primary-500/30 border"
|
||||
title="Download as: {computed_name}">
|
||||
<Download size="0.9em" class="mr-0.5" />
|
||||
Download
|
||||
</a>
|
||||
<MyClipboard
|
||||
value={dl_url}
|
||||
btn_text="Copy Link"
|
||||
btn_title="Copy direct download link: {computed_name}"
|
||||
btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ---- Presenter-level files ---- -->
|
||||
{#each sg.presenter_groups as pg (pg.presenter_id)}
|
||||
<div class="py-2">
|
||||
<h4 class="mb-1 flex items-center gap-1 text-xs font-bold tracking-wider opacity-60">
|
||||
<User size="0.9em" />
|
||||
{pg.presenter_full_name || '— presenter name not set —'}
|
||||
{#if pg.presentation_name}
|
||||
<span class="font-normal normal-case opacity-80">— {pg.presentation_name}</span>
|
||||
{/if}
|
||||
<span class="font-normal normal-case">({pg.files.length} file{pg.files.length !== 1 ? 's' : ''})</span>
|
||||
</h4>
|
||||
<table class="w-full table-auto text-sm">
|
||||
<tbody>
|
||||
{#each pg.files as file (file.event_file_id)}
|
||||
{@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)}
|
||||
{@const computed_name = build_filename(file, selected_format)}
|
||||
{@const dl_url = build_download_url(file, selected_format)}
|
||||
<tr class="hover:bg-surface-100-900 border-surface-100-900 border-t transition-colors">
|
||||
<td class="py-1 pr-2">
|
||||
<span class="flex items-center gap-1 text-xs opacity-50" title={file.filename}>
|
||||
<ExtIcon size="0.9em" />
|
||||
{ae_util.shorten_filename({ filename: file.filename, max_length: 30 })}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-1">
|
||||
<span class="font-mono text-xs opacity-70" title="Filename that will be used for download">
|
||||
{computed_name}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-1 py-1 text-right text-xs opacity-50 whitespace-nowrap">
|
||||
{ae_util.format_bytes(file.file_size)}
|
||||
</td>
|
||||
<td class="pl-2 py-1 whitespace-nowrap">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<a
|
||||
href={dl_url}
|
||||
download={computed_name}
|
||||
class="btn btn-xs preset-tonal-primary border-primary-500/30 border"
|
||||
title="Download as: {computed_name}">
|
||||
<Download size="0.9em" class="mr-0.5" />
|
||||
Download
|
||||
</a>
|
||||
<MyClipboard
|
||||
value={dl_url}
|
||||
btn_text="Copy Link"
|
||||
btn_title="Copy direct download link: {computed_name}"
|
||||
btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
Reference in New Issue
Block a user