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:
@@ -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