refactor(ui): standardize button types and migrate file operations to V3 Action API
This commit is contained in:
@@ -23,19 +23,19 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[9px] font-bold uppercase opacity-50 ml-1">Operational Environment</label>
|
||||
<div class="grid grid-cols-3 gap-1 bg-surface-500/5 p-1 rounded-lg">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => $events_loc.launcher.app_mode = 'default'}
|
||||
class="btn btn-xs text-[9px] font-bold"
|
||||
class:preset-filled-primary-500={$events_loc.launcher.app_mode === 'default'}
|
||||
class:opacity-40={$events_loc.launcher.app_mode !== 'default'}
|
||||
>Web</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => $events_loc.launcher.app_mode = 'native'}
|
||||
class="btn btn-xs text-[9px] font-bold"
|
||||
class:preset-filled-primary-500={$events_loc.launcher.app_mode === 'native'}
|
||||
class:opacity-40={$events_loc.launcher.app_mode !== 'native'}
|
||||
>App</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => $events_loc.launcher.app_mode = 'onsite'}
|
||||
class="btn btn-xs text-[9px] font-bold"
|
||||
class:preset-filled-primary-500={$events_loc.launcher.app_mode === 'onsite'}
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 3. Time Format Toggle -->
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
if ($events_loc.launcher.time_format == 'time_12_short') {
|
||||
$events_loc.launcher.time_format = 'time_short';
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<!-- 3. Connection Actions -->
|
||||
<div class="grid grid-cols-2 gap-2 mt-1">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
if ($events_loc.launcher.ws_connect) {
|
||||
$events_sess.launcher.trigger__ws_disconnect = true;
|
||||
@@ -70,7 +70,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
$events_sess.launcher.controller_cmd = 'ae_refresh:now';
|
||||
$events_sess.launcher.controller_trigger_send = 'trigger';
|
||||
@@ -94,7 +94,7 @@
|
||||
readonly={!$events_sess.launcher.controller_unlock_group_code}
|
||||
ondblclick={() => $events_sess.launcher.controller_unlock_group_code = true}
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => $events_sess.launcher.controller_unlock_group_code = !$events_sess.launcher.controller_unlock_group_code}
|
||||
class="btn btn-xs preset-tonal-surface"
|
||||
title="Toggle Unlock"
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
<!-- 2. UI Toggles -->
|
||||
<div class="grid grid-cols-2 gap-2 mt-1">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => ($ae_loc.sys_menu.hide = !$ae_loc.sys_menu.hide)}
|
||||
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
|
||||
title="Show/Hide Aether global system menu"
|
||||
@@ -85,7 +85,7 @@
|
||||
{$ae_loc.sys_menu.hide ? 'Show' : 'Hide'} Sys Menu
|
||||
</button>
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => ($ae_loc.debug_menu.hide = !$ae_loc.debug_menu.hide)}
|
||||
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
|
||||
title="Show/Hide Aether global debug menu"
|
||||
|
||||
@@ -58,25 +58,25 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-[9px] font-bold uppercase opacity-50 ml-1">Folders & View</label>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => native.open_folder($ae_loc.local_file_cache_path)}
|
||||
class="btn btn-xs preset-tonal-primary hover:preset-filled-primary-500 justify-start"
|
||||
>
|
||||
<span class="fas fa-folder-open mr-2 w-3"></span> Cache
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => native.open_folder($ae_loc.host_file_temp_path)}
|
||||
class="btn btn-xs preset-tonal-primary hover:preset-filled-primary-500 justify-start"
|
||||
>
|
||||
<span class="fas fa-folder-open mr-2 w-3"></span> Temp
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => native.window_control({ action: 'maximize' })}
|
||||
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
|
||||
>
|
||||
<span class="fas fa-expand mr-1"></span> Maximize
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => handle_system_action(native.window_control({ action: 'kiosk', value: true }), 'Kiosk Mode')}
|
||||
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500"
|
||||
>
|
||||
@@ -96,16 +96,16 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
<button onclick={() => handle_remote_control('prev')} class="btn btn-sm preset-tonal-secondary" title="Previous Slide">
|
||||
<button type="button" onclick={() => handle_remote_control('prev')} class="btn btn-sm preset-tonal-secondary" title="Previous Slide">
|
||||
<span class="fas fa-step-backward"></span>
|
||||
</button>
|
||||
<button onclick={() => handle_remote_control('start')} class="btn btn-sm preset-tonal-success" title="Start/Resume Slideshow">
|
||||
<button type="button" onclick={() => handle_remote_control('start')} class="btn btn-sm preset-tonal-success" title="Start/Resume Slideshow">
|
||||
<span class="fas fa-play"></span>
|
||||
</button>
|
||||
<button onclick={() => handle_remote_control('stop')} class="btn btn-sm preset-tonal-error" title="Stop Slideshow">
|
||||
<button type="button" onclick={() => handle_remote_control('stop')} class="btn btn-sm preset-tonal-error" title="Stop Slideshow">
|
||||
<span class="fas fa-stop"></span>
|
||||
</button>
|
||||
<button onclick={() => handle_remote_control('next')} class="btn btn-sm preset-tonal-secondary" title="Next Slide">
|
||||
<button type="button" onclick={() => handle_remote_control('next')} class="btn btn-sm preset-tonal-secondary" title="Next Slide">
|
||||
<span class="fas fa-step-forward"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -121,13 +121,13 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[9px] font-bold uppercase opacity-50">System Actions</label>
|
||||
<div class="grid grid-cols-1 gap-1">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => handle_system_action(native.set_display_layout({ mode: 'extend' }), 'Extend Display')}
|
||||
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500 justify-start"
|
||||
>
|
||||
<span class="fas fa-columns mr-2 w-3"></span> Extend Mode
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => handle_system_action(native.set_wallpaper({ path: $ae_loc.site_header_image_path }), 'Wallpaper')}
|
||||
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500 justify-start"
|
||||
disabled={!$ae_loc.site_header_image_path}
|
||||
@@ -139,13 +139,13 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[9px] font-bold uppercase opacity-50 text-error-500">Power</label>
|
||||
<div class="grid grid-cols-1 gap-1">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => show_power_confirm = { action: 'reboot', label: 'Reboot Laptop' }}
|
||||
class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 justify-start"
|
||||
>
|
||||
<span class="fas fa-sync-alt mr-2 w-3"></span> Reboot
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => show_power_confirm = { action: 'shutdown', label: 'Shutdown Laptop' }}
|
||||
class="btn btn-xs preset-tonal-error hover:preset-filled-error-500 justify-start"
|
||||
>
|
||||
@@ -164,7 +164,7 @@
|
||||
placeholder="ls -la"
|
||||
class="input input-sm grow text-[10px] preset-tonal-surface h-7"
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={async () => {
|
||||
test_cmd_result = 'Running...';
|
||||
const res = await native.run_cmd({ cmd: $events_sess.launcher.manual_cmd || 'uptime' });
|
||||
@@ -190,8 +190,8 @@
|
||||
Are you sure you want to <strong>{show_power_confirm.action}</strong> this host machine?
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick={() => show_power_confirm = null} class="btn btn-sm preset-tonal-surface">Cancel</button>
|
||||
<button
|
||||
<button type="button" onclick={() => show_power_confirm = null} class="btn btn-sm preset-tonal-surface">Cancel</button>
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
const action = show_power_confirm?.action;
|
||||
show_power_confirm = null;
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Pin Toggle -->
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={toggle_pin}
|
||||
class="btn btn-icon btn-xs transition-all hover:scale-110"
|
||||
class:opacity-20={state !== 'pinned'}
|
||||
|
||||
@@ -58,13 +58,13 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => handle_test_action('Primary')}
|
||||
class="btn btn-xs preset-tonal-primary hover:preset-filled-primary-500 justify-start"
|
||||
>
|
||||
<span class="fas fa-bolt mr-2 w-3 text-center"></span> Primary
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => handle_test_action('Secondary')}
|
||||
class="btn btn-xs preset-tonal-secondary hover:preset-filled-secondary-500 justify-start"
|
||||
>
|
||||
@@ -107,7 +107,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => handle_test_action('Refresh')}
|
||||
class="btn btn-xs preset-outlined-surface-500 w-full text-[10px]"
|
||||
>
|
||||
@@ -124,7 +124,7 @@
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[9px] font-bold uppercase opacity-50 text-warning-500">System Config</label>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => show_confirm = true}
|
||||
class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 justify-start"
|
||||
>
|
||||
@@ -133,7 +133,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[9px] font-bold uppercase opacity-50 text-error-500">Danger Zone</label>
|
||||
<button class="btn btn-xs preset-tonal-error hover:preset-filled-error-500 justify-start">
|
||||
<button type="button" class="btn btn-xs preset-tonal-error hover:preset-filled-error-500 justify-start">
|
||||
<span class="fas fa-trash-alt mr-2 w-3"></span> Wipe Cache
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,8 +190,8 @@
|
||||
Are you sure you want to perform this test operation? This demonstrate the standard confirmation pattern.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick={() => show_confirm = false} class="btn btn-sm preset-tonal-surface">Cancel</button>
|
||||
<button
|
||||
<button type="button" onclick={() => show_confirm = false} class="btn btn-sm preset-tonal-surface">Cancel</button>
|
||||
<button type="button"
|
||||
onclick={() => { show_confirm = false; handle_test_action('Confirm'); }}
|
||||
class="btn btn-sm preset-filled-warning"
|
||||
>
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- COMMON: Check Button -->
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={handle_check_update}
|
||||
disabled={is_checking}
|
||||
class="btn btn-sm preset-filled-tertiary hover:preset-filled-primary-500 text-[10px] w-full"
|
||||
@@ -110,7 +110,7 @@
|
||||
{/if}
|
||||
|
||||
{#if download_result}
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={handle_install}
|
||||
class="btn btn-sm preset-filled-success hover:preset-filled-primary-500 text-[10px] w-full animate-bounce mt-2 shadow-lg"
|
||||
>
|
||||
|
||||
@@ -376,7 +376,7 @@
|
||||
"
|
||||
>
|
||||
<h3 class="h4 text-center italic text-surface-600-400">
|
||||
<button
|
||||
<button type="button"
|
||||
class=""
|
||||
onclick={() => {
|
||||
$events_loc.launcher.hide__launcher_menu =
|
||||
@@ -400,7 +400,7 @@
|
||||
class="h4 text-center italic text-surface-600-400"
|
||||
title="Location ID: {$lq__event_location_obj?.event_location_id} Name: {$lq__event_location_obj?.name}"
|
||||
>
|
||||
<button
|
||||
<button type="button"
|
||||
class="text-base"
|
||||
onclick={() => {
|
||||
$ae_loc.edit_mode = !$ae_loc.edit_mode;
|
||||
@@ -537,7 +537,7 @@
|
||||
class="slct_location_name transition-all duration-1000"
|
||||
title="Location ID: {$lq__event_location_obj?.event_location_id} Name: {$lq__event_location_obj?.name} | Device ID: {$lq__event_device_obj?.event_device_id} Name: {$lq__event_device_obj?.name}"
|
||||
>
|
||||
<button
|
||||
<button type="button"
|
||||
class=""
|
||||
onclick={() => {
|
||||
$ae_loc.edit_mode = !$ae_loc.edit_mode;
|
||||
@@ -612,8 +612,7 @@
|
||||
</footer>
|
||||
|
||||
<div class="absolute top-0 left-0 z-20 text-center">
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onclick={() => ($events_loc.launcher.hide_drawer__cfg = false)}
|
||||
class="btn btn-sm p-2.5 preset-tonal-error hover:preset-filled-error-500 transition-all duration-1000"
|
||||
class:opacity-25={!$ae_loc.trusted_access}
|
||||
@@ -686,7 +685,7 @@
|
||||
<h2 class="text-center mb-4 text-base font-semibold text-gray-500 dark:text-gray-400">
|
||||
Debug
|
||||
</h2>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => ($events_loc.launcher.hide_drawer__debug = true)}
|
||||
class="mb-4 dark:text-white"
|
||||
>
|
||||
@@ -730,8 +729,7 @@
|
||||
>
|
||||
{$events_sess.launcher?.modal__title ?? 'Digital Poster Display'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
class="btn flex-row-reverse group transition-all justify-self-end"
|
||||
onclick={() => {
|
||||
$events_sess.launcher.modal__open_event_file_id = null;
|
||||
@@ -743,7 +741,7 @@
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
$events_sess.launcher.controller_cmd = `ae_close:event_file_modal`;
|
||||
$events_sess.launcher.controller_trigger_send = true;
|
||||
@@ -780,7 +778,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
$events_sess.launcher.controller_cmd = `ae_close:event_file_modal`;
|
||||
$events_sess.launcher.controller_trigger_send = true;
|
||||
@@ -802,7 +800,7 @@
|
||||
Close Remote Poster Display Only
|
||||
</button>
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
$events_sess.launcher.modal__title = '';
|
||||
$events_sess.launcher.modal__open_event_file_id = null;
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
<div class="bg-black/90 text-white p-3 rounded-lg border border-primary-500 shadow-2xl text-[10px] font-mono min-w-48 pointer-events-auto">
|
||||
<div class="flex justify-between border-b border-primary-500 pb-1 mb-2">
|
||||
<span class="font-bold text-primary-400">NATIVE SYNC MONITOR</span>
|
||||
<button onclick={() => show_monitor = false} class="text-error-500 hover:text-error-400">×</button>
|
||||
<button type="button" onclick={() => show_monitor = false} class="text-error-500 hover:text-error-400">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 mb-2">
|
||||
@@ -286,7 +286,7 @@
|
||||
{/if}
|
||||
|
||||
{#if currently_syncing}
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => show_monitor = !show_monitor}
|
||||
class="bg-black/80 text-white p-2 rounded-lg text-[10px] border border-primary-500 animate-pulse shadow-2xl pointer-events-auto transition-transform active:scale-95"
|
||||
>
|
||||
@@ -301,7 +301,7 @@
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Secret button area to toggle monitor when not syncing -->
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => show_monitor = !show_monitor}
|
||||
class="w-8 h-8 opacity-0 hover:opacity-20 bg-primary-500 rounded-full pointer-events-auto flex items-center justify-center transition-opacity"
|
||||
title="Toggle Sync Monitor"
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
Launcher Configuration
|
||||
</h2>
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => ($events_loc.launcher.hide_drawer__cfg = true)}
|
||||
class="btn btn-icon dark:text-white hover:bg-surface-500/10 transition-colors"
|
||||
>
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="w-full grid grid-cols-3 gap-1 bg-surface-500/10 p-1 rounded-lg">
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => active_tab = 'system'}
|
||||
class="btn btn-sm text-[10px] uppercase font-bold transition-all"
|
||||
class:preset-filled-primary-500={active_tab === 'system'}
|
||||
@@ -85,7 +85,7 @@
|
||||
>
|
||||
<span class="fas fa-microchip mr-1"></span> System
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => active_tab = 'sync'}
|
||||
class="btn btn-sm text-[10px] uppercase font-bold transition-all"
|
||||
class:preset-filled-primary-500={active_tab === 'sync'}
|
||||
@@ -93,7 +93,7 @@
|
||||
>
|
||||
<span class="fas fa-sync mr-1"></span> Sync
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => active_tab = 'general'}
|
||||
class="btn btn-sm text-[10px] uppercase font-bold transition-all"
|
||||
class:preset-filled-primary-500={active_tab === 'general'}
|
||||
@@ -140,8 +140,7 @@
|
||||
<!-- Global Actions Footer -->
|
||||
<div class="w-full flex flex-col gap-2 border-t border-surface-500/20 pt-4 mt-auto">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onclick={() => ($events_loc.launcher.hide_drawer__debug = false)}
|
||||
class="btn btn-sm preset-tonal-error hover:preset-filled-error-500 transition-all"
|
||||
>
|
||||
@@ -149,8 +148,7 @@
|
||||
Open Debug
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onclick={() => location.reload()}
|
||||
class="btn btn-sm preset-tonal-secondary hover:preset-filled-secondary-500 transition-all"
|
||||
>
|
||||
|
||||
@@ -301,8 +301,7 @@
|
||||
events_func.load_ae_obj_id__event_file({ api_cfg: $ae_api, event_file_id: event_file_obj?.event_file_id, log_lvl });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
if (!event_file_obj?.open_in_os) ae_tmp.value__open_in_os = 'win';
|
||||
else if (event_file_obj?.open_in_os == 'win') ae_tmp.value__open_in_os = 'mac';
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
flex flex-row gap-1 items-center justify-center
|
||||
"
|
||||
>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
if ($events_loc.launcher.show_content__hidden_files) {
|
||||
$events_loc.launcher.show_content__hidden_files = false;
|
||||
@@ -242,7 +242,7 @@
|
||||
All Files
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
$events_loc.launcher.show_content__hidden_sessions =
|
||||
!$events_loc.launcher.show_content__hidden_sessions;
|
||||
|
||||
@@ -277,8 +277,7 @@
|
||||
$events_loc.launcher.hide__session_datetimes}
|
||||
class="shrink event_session_datetimes"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onclick={() => {
|
||||
if ($events_loc.launcher.time_format == 'time_12_short') {
|
||||
// $events_loc.launcher.datetime_format = 'datetime_long';
|
||||
@@ -370,7 +369,7 @@
|
||||
</strong>
|
||||
</div>
|
||||
<!-- {#if $ae_loc.trusted_access || $events_loc.launcher.trusted_access}
|
||||
<button on:click={async () => {
|
||||
<button type="button" on:click={async () => {
|
||||
show_modal_upload_files = true;
|
||||
link_to_type = 'event_session';
|
||||
link_to_id = $lq__event_session_obj.event_session_id;
|
||||
|
||||
@@ -177,8 +177,7 @@
|
||||
transition-all
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onmouseenter={() => {
|
||||
// Start a 750 ms timer to prevent changing the session too quickly.
|
||||
hover_timer = setTimeout(async () => {
|
||||
|
||||
Reference in New Issue
Block a user