feat(hosted-files): introduce direct download mode and automate filename shortening

- Add 'show_direct_download' prop to support native browser downloads via V3 Action paths (/v3/action/...).
- Integrate 'shorten_filename' logic directly into the component via 'max_filename' prop.
- Refactor internal UI to snippets for consistent rendering across button and anchor modes.
- Update 'ae_HostedFile' type with new SQL view fields: 'filename_no_ext' and 'filename_w_ext'.
- Add direct download toggle and edit mode indicator to testing dashboard.
- Simplify 'ae_comp__event_file_obj_tbl' by removing manual snippets in favor of standardized component logic.
This commit is contained in:
Scott Idem
2026-02-03 15:20:32 -05:00
parent 671247e783
commit 6634c9aef0
4 changed files with 168 additions and 119 deletions

View File

@@ -18,7 +18,7 @@
hosted_file_id: null | string;
hosted_file_obj: null | key_val;
filename?: null | string;
max_length?: number;
max_filename?: number;
auto_download?: boolean;
linked_to_type?: null | string;
linked_to_id?: null | string;
@@ -28,16 +28,17 @@
variant?: 'tonal' | 'filled' | 'outline' | 'ghost';
color?: 'primary' | 'secondary' | 'tertiary' | 'success' | 'warning' | 'error' | 'surface';
show_divider?: boolean;
show_direct_download?: boolean;
classes?: string;
label?: import('svelte').Snippet;
}
let {
let {
log_lvl = 0,
hosted_file_id,
hosted_file_obj,
filename = $bindable(null),
max_length = $bindable(30),
max_filename = $bindable(30),
auto_download = true,
linked_to_type = $bindable(null),
linked_to_id = $bindable(null),
@@ -47,6 +48,7 @@
variant = 'tonal',
color = 'primary',
show_divider = true,
show_direct_download = false,
classes = '',
label
}: Props = $props();
@@ -98,7 +100,7 @@
};
let variant_classes = $derived.by(() => {
const base = 'btn btn-sm lg:btn-md min-w-48 transition-all overflow-hidden';
const base = 'btn btn-sm lg:btn-md min-w-48 transition-all overflow-hidden px-3';
const style = color_map[color]?.[variant] || color_map.primary.tonal;
return `${base} ${style} ${classes}`.trim();
});
@@ -151,109 +153,134 @@
}
};
});
let final_filename = $derived(filename ?? hosted_file_obj?.filename ?? 'unknown');
let shortened_filename = $derived(ae_util.shorten_filename({
filename: final_filename,
max_length: max_filename
}));
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.
const file_id = hosted_file_obj.event_file_id || hosted_file_obj.hosted_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)}&x_no_account_id_token=direct-download`;
});
</script>
{#if hosted_file_id && hosted_file_obj}
{@const file_id = hosted_file_obj.id || hosted_file_obj.hosted_file_id || hosted_file_id}
<button
type="button"
disabled={!$ae_loc.trusted_access}
class={variant_classes}
onclick={() => {
download_complete = undefined;
download_status_msg = 'Downloading...';
ae_promises[file_id] = download_ae_obj_id__hosted_file({
api_cfg: $ae_api,
hosted_file_id: file_id,
return_file: true,
filename: filename ?? hosted_file_obj.filename,
auto_download: auto_download,
log_lvl: log_lvl
})
.then((result) => {
if (result === null) {
console.log('File not found (404)');
download_complete = null;
download_status_msg = 'File not found';
} else if (result === false) {
console.log(
'Possible error with API server (check network and server status)'
);
download_complete = false;
download_status_msg = 'Failed to download';
} else {
// console.log('File found and downloaded');
download_complete = true;
download_status_msg = 'File downloaded';
}
return result;
});
}}
title={`Download this file:\n${filename ?? hosted_file_obj?.filename}\n[API] SHA256: ${hosted_file_obj?.hash_sha256?.slice(0, 10)}...\nHosted ID: ${file_id}\n Linked to: ${linked_to_type} ID: ${linked_to_id}`}
>
{#await ae_promises[file_id]}
<div class="flex items-center w-full min-h-[1.5rem]">
{#snippet content()}
{@const file_id = hosted_file_obj?.id || hosted_file_obj?.hosted_file_id || hosted_file_id}
{#await ae_promises[file_id]}
<div class="flex items-center w-full min-h-[1.5rem]">
<div
class="flex items-center pr-2 shrink-0 {show_divider ? 'border-r border-surface-500/30 mr-2' : ''}"
>
<Lucide.Loader2 class="animate-spin" size={18} />
</div>
<div class="grow relative text-left h-full">
{#if show_filename_view}
<div in:fade={{ duration: 250 }} out:fade={{ duration: 250 }} class="flex items-center h-full">
<span class="truncate">
{shortened_filename}
</span>
</div>
{:else}
<div in:fade={{ duration: 250 }} out:fade={{ duration: 250 }} class="absolute inset-0 flex items-center h-full">
<span class="font-bold whitespace-nowrap">
Downloading:
{#if $ae_sess.api_download_kv[file_id]}
{$ae_sess.api_download_kv[file_id].percent_completed}%
{:else}
...
{/if}
</span>
</div>
{/if}
</div>
</div>
{:then}
{#if label}
{@render label()}
{:else}
{@const IconComp = ae_util.file_extension_icon_lucide(hosted_file_obj?.extension)}
<div class="flex items-center w-full">
<div
class="flex items-center pr-2 shrink-0 {show_divider ? 'border-r border-surface-500/30 mr-2' : ''}"
>
<Lucide.Loader2 class="animate-spin" size={18} />
<IconComp size={18} />
</div>
<div class="grow relative text-left h-full">
{#if show_filename_view}
<div in:fade={{ duration: 250 }} out:fade={{ duration: 250 }} class="flex items-center h-full">
<span class="truncate">
{ae_util.shorten_filename({
filename: filename ?? hosted_file_obj?.filename,
max_length: max_length
})}
</span>
</div>
{:else}
<div in:fade={{ duration: 250 }} out:fade={{ duration: 250 }} class="absolute inset-0 flex items-center h-full">
<span class="font-bold whitespace-nowrap">
Downloading:
{#if $ae_sess.api_download_kv[file_id]}
{$ae_sess.api_download_kv[file_id].percent_completed}%
{:else}
...
{/if}
</span>
</div>
{/if}
</div>
</div>
{:then}
{#if label}
{@render label()}
{:else}
{@const IconComp = ae_util.file_extension_icon_lucide(hosted_file_obj?.extension)}
<div class="flex items-center w-full">
<div
class="flex items-center pr-2 shrink-0 {show_divider ? 'border-r border-surface-500/30 mr-2' : ''}"
>
<IconComp size={18} />
</div>
<span class="grow truncate text-left">
{ae_util.shorten_filename({
filename: filename ?? hosted_file_obj?.filename,
max_length: max_length
})}
<span class="grow truncate text-left">
{shortened_filename}
</span>
{#if hosted_file_obj?.file_purpose || hosted_file_obj?.group}
<span class="badge preset-tonal-success ml-2 text-[10px] uppercase font-bold shrink-0">
{hosted_file_obj.file_purpose || hosted_file_obj.group}
</span>
{#if hosted_file_obj?.file_purpose || hosted_file_obj?.group}
<span class="badge preset-tonal-success ml-2 text-[10px] uppercase font-bold shrink-0">
{hosted_file_obj.file_purpose || hosted_file_obj.group}
</span>
{/if}
</div>
{/if}
{/await}
{#if download_complete === null}
<span class="text-red-800 dark:text-red-200 ml-2 whitespace-nowrap">File not found</span>
{:else if download_complete === false}
<span class="text-red-800 dark:text-red-200 ml-2 whitespace-nowrap text-xs">Failed!</span>
{/if}
</div>
{/if}
</button>
{/await}
{#if download_complete === null}
<span class="text-red-800 dark:text-red-200 ml-2 whitespace-nowrap">File not found</span>
{:else if download_complete === false}
<span class="text-red-800 dark:text-red-200 ml-2 whitespace-nowrap text-xs">Failed!</span>
{/if}
{/snippet}
{#if hosted_file_id && hosted_file_obj}
{@const file_id = hosted_file_obj.id || hosted_file_obj.hosted_file_id || hosted_file_id}
{#if show_direct_download}
<a
href={direct_download_url}
class={variant_classes}
title={`Direct download (V3 Action):\n${final_filename}\n[API] SHA256: ${hosted_file_obj?.hash_sha256?.slice(0, 10)}...\nHosted ID: ${file_id}`}
>
{@render content()}
</a>
{:else}
<button
type="button"
disabled={!$ae_loc.trusted_access}
class={variant_classes}
onclick={() => {
download_complete = undefined;
download_status_msg = 'Downloading...';
ae_promises[file_id] = download_ae_obj_id__hosted_file({
api_cfg: $ae_api,
hosted_file_id: file_id,
return_file: true,
filename: final_filename,
auto_download: auto_download,
log_lvl: log_lvl
})
.then((result) => {
if (result === null) {
console.log('File not found (404)');
download_complete = null;
download_status_msg = 'File not found';
} else if (result === false) {
console.log(
'Possible error with API server (check network and server status)'
);
download_complete = false;
download_status_msg = 'Failed to download';
} else {
// console.log('File found and downloaded');
download_complete = true;
download_status_msg = 'File downloaded';
}
return result;
});
}}
title={`Download this file:\n${final_filename}\n[API] SHA256: ${hosted_file_obj?.hash_sha256?.slice(0, 10)}...\nHosted ID: ${file_id}\n Linked to: ${linked_to_type} ID: ${linked_to_id}`}
>
{@render content()}
</button>
{/if}
{:else}
<button type="button" disabled class={variant_classes} title="No file selected">
<div class="flex items-center w-full">
@@ -265,4 +292,4 @@
<span class="grow text-left"> No file info </span>
</div>
</button>
{/if}
{/if}