feat(pres_mgmt): prefix/suffix inputs, flex row layout, strip :443 from links

File Downloads report: add Prefix and Suffix inputs for filename customization
(e.g. "2026_06__"), longest-filename length indicator with warning above 120
chars, and switch file rows from table to flex layout so Download/Copy Link
buttons stay right-aligned regardless of filename length.

Strip redundant :443 from https download URLs in both the file downloads report
and the manage event file list clipboard links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-10 15:15:16 -04:00
parent e909c34874
commit e8a49562a9
2 changed files with 205 additions and 130 deletions

View File

@@ -78,6 +78,9 @@ let ae_promises: key_val = $state({});
let ae_tmp: key_val = $state({});
ae_tmp.show__file_li = true;
ae_tmp.show__direct_download = pres_mgmt_loc.current.show__direct_download;
// Strip :443 from https URLs — redundant and clutters shareable links.
let base_url = $derived($ae_api.base_url.replace(/^(https:\/\/[^/:]+):443(\/|$)/, '$1$2'));
// let ae_triggers: key_val = {};
onMount(() => {
@@ -317,7 +320,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}`
`${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}`
)}
btn_text="Copy Original"
btn_title="Copy the direct download link to the clipboard."
@@ -326,7 +329,7 @@ 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=${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}`
`${base_url}/v3/action/event_file/${event_file_obj?.event_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. Format: [session-code]_[presentation-name]_[presenter-name].[ext]"

View File

@@ -20,7 +20,7 @@ let {
}: Props = $props();
import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { ae_api } 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';
@@ -30,15 +30,25 @@ import {
FileText,
LoaderCircle,
RefreshCw,
User,
Users
TriangleAlert,
User
} from '@lucide/svelte';
// ---------------------------------------------------------------------------
// Filename helpers
// ---------------------------------------------------------------------------
// Strip OS-unsafe chars AND periods (no periods in filename stems).
let prefix_val = $state('');
let suffix_val = $state('');
// For prefix/suffix: clean OS-unsafe chars and periods, but preserve leading/trailing
// underscores — users intentionally use them as separators (e.g. "2026_06__").
function clean_affix(s: string): string {
if (!s) return '';
return ae_util.clean_filename(s).replace(/\./g, '_');
}
// For filename parts: also strip leading/trailing underscores and collapse runs.
function clean_part(s: any, max?: number): string {
if (!s) return '';
const out = ae_util
@@ -71,19 +81,18 @@ type FormatKey =
| 'session_date_presentation_presenter';
const FORMAT_PRESETS: Record<FormatKey, { label: string; group: string }> = {
original: { label: 'Original filename', group: 'Universal' },
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_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'],
@@ -92,43 +101,73 @@ const FORMAT_GROUPS: Record<string, FormatKey[]> = {
let selected_format = $state<FormatKey>('session_code_name');
const FILENAME_WARN_LEN = 120; // show warning above this char count
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);
const ext = file.extension ?? 'file';
const pre = clean_affix(prefix_val);
const suf = clean_affix(suffix_val);
if (fmt === 'original') return file.filename ?? `file.${ext}`;
let stem: string;
if (fmt === 'original') {
// Preserve the original stem as-is; only the prefix/suffix are user-controlled.
stem = file.filename_no_ext ?? (file.filename ?? 'file').replace(/\.[^.]+$/, '');
} else {
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);
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}`;
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: stem = file.filename_no_ext ?? 'file'; return `${pre}${stem}${suf}.${ext}`;
}
stem = parts.filter(Boolean).join('_').replace(/_+/g, '_').replace(/^_|_$/, '');
}
const stem = parts.filter(Boolean).join('_').replace(/_+/g, '_').replace(/^_|_$/, '');
return `${stem}.${ext}`;
return `${pre}${stem}${suf}.${ext}`;
}
// Strip :443 from https URLs — redundant and clutters shareable links.
let base_url = $derived($ae_api.base_url.replace(/^(https:\/\/[^/:]+):443(\/|$)/, '$1$2'));
function build_download_url(file: any, fmt: FormatKey): string {
const fname = build_filename(file, fmt);
return encodeURI(
`${$ae_api.base_url}/v3/action/event_file/${file.event_file_id}/download?filename=${ae_util.clean_filename(fname)}&key=${$ae_api.account_id}`
`${base_url}/v3/action/event_file/${file.event_file_id}/download?filename=${ae_util.clean_filename(fname)}&key=${$ae_api.account_id}`
);
}
// Longest computed filename across all files — reactive to format + prefix + suffix changes.
let max_computed_len = $derived.by(() => {
let max = 0;
for (const sg of session_groups) {
for (const f of sg.session_files) {
const len = build_filename(f, selected_format).length;
if (len > max) max = len;
}
for (const pg of sg.presenter_groups) {
for (const f of pg.files) {
const len = build_filename(f, selected_format).length;
if (len > max) max = len;
}
}
}
return max;
});
// ---------------------------------------------------------------------------
// Grouping
// ---------------------------------------------------------------------------
@@ -147,8 +186,8 @@ type SessionGroup = {
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'
session_files: any[];
presenter_groups: PresenterGroup[];
_presenter_map: Record<string, PresenterGroup>;
};
@@ -199,8 +238,8 @@ let session_groups = $derived.by((): SessionGroup[] => {
});
// 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.
// Presenter-level columns are view-only joined fields — per API guide §3B they are
// silently dropped from ORDER BY, so sorting must be done client-side.
for (const sg of sorted) {
sg.presenter_groups.sort((a, b) => {
const fam = a.presenter_family_name.localeCompare(b.presenter_family_name);
@@ -212,8 +251,8 @@ let session_groups = $derived.by((): SessionGroup[] => {
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));
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
@@ -301,7 +340,7 @@ async function handle_qry() {
<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:
Format:
<select
class="select ae_btn_info w-auto max-w-xs text-sm"
bind:value={selected_format}>
@@ -314,9 +353,38 @@ async function handle_qry() {
{/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>
<!-- Prefix / suffix -->
<div class="flex flex-row flex-wrap items-center gap-3 text-sm">
<label class="flex flex-row items-center gap-1 font-semibold">
Prefix:
<input
type="text"
class="input text-sm w-48"
placeholder="e.g. 2026_06__"
bind:value={prefix_val} />
</label>
<label class="flex flex-row items-center gap-1 font-semibold">
Suffix:
<input
type="text"
class="input text-sm w-36"
placeholder="e.g. __final"
bind:value={suffix_val} />
</label>
{#if qry__count > 0}
<span
class="text-xs"
class:text-warning-600={max_computed_len > FILENAME_WARN_LEN}
class:opacity-50={max_computed_len <= FILENAME_WARN_LEN}
title="Longest computed filename across all files">
{#if max_computed_len > FILENAME_WARN_LEN}
<TriangleAlert size="0.9em" class="mr-0.5 inline-block" />
{/if}
longest: {max_computed_len} chars
</span>
{/if}
</div>
</header>
@@ -370,49 +438,51 @@ async function handle_qry() {
<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 class="flex flex-col">
{#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)}
{@const is_long = computed_name.length > FILENAME_WARN_LEN}
<div class="border-surface-100-900 hover:bg-surface-100-900 flex flex-row items-center gap-2 border-t py-1.5 transition-colors">
<!-- Left: filenames (grows, min-w-0 allows truncation) -->
<div class="flex min-w-0 grow flex-col gap-0.5">
<span class="flex items-center gap-1 truncate text-xs opacity-50" title={file.filename}>
<ExtIcon size="0.9em" class="shrink-0" />
{ae_util.shorten_filename({ filename: file.filename, max_length: 40 })}
</span>
<span
class="truncate font-mono text-xs"
class:opacity-70={!is_long}
class:text-warning-600={is_long}
title="{computed_name}{is_long ? ` (${computed_name.length} chars may be long for some systems)` : ''}">
{computed_name}
</span>
</div>
<!-- File size -->
<span class="shrink-0 whitespace-nowrap text-xs opacity-50">
{ae_util.format_bytes(file.file_size)}
</span>
<!-- Buttons -->
<div class="flex shrink-0 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>
</div>
{/each}
</div>
</div>
{/if}
@@ -427,49 +497,51 @@ async function handle_qry() {
{/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 class="flex flex-col">
{#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)}
{@const is_long = computed_name.length > FILENAME_WARN_LEN}
<div class="border-surface-100-900 hover:bg-surface-100-900 flex flex-row items-center gap-2 border-t py-1.5 transition-colors">
<!-- Left: filenames (grows, min-w-0 allows truncation) -->
<div class="flex min-w-0 grow flex-col gap-0.5">
<span class="flex items-center gap-1 truncate text-xs opacity-50" title={file.filename}>
<ExtIcon size="0.9em" class="shrink-0" />
{ae_util.shorten_filename({ filename: file.filename, max_length: 40 })}
</span>
<span
class="truncate font-mono text-xs"
class:opacity-70={!is_long}
class:text-warning-600={is_long}
title="{computed_name}{is_long ? ` (${computed_name.length} chars may be long for some systems)` : ''}">
{computed_name}
</span>
</div>
<!-- File size -->
<span class="shrink-0 whitespace-nowrap text-xs opacity-50">
{ae_util.format_bytes(file.file_size)}
</span>
<!-- Buttons -->
<div class="flex shrink-0 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>
</div>
{/each}
</div>
</div>
{/each}