Organize account controls into tabs
This commit is contained in:
@@ -3,6 +3,19 @@
|
||||
let { data, form } = $props();
|
||||
const user = $derived(data.user!);
|
||||
let discordLinkPending = $state(false);
|
||||
type AccountPanel = 'identity' | 'access' | 'connections' | 'account';
|
||||
type AvatarPanel = 'presets' | 'upload';
|
||||
function initialAccountPanel(): AccountPanel {
|
||||
if (form?.discordUnlinkError || data.oauthError || data.discordLinked || form?.discordUnlinked) return 'connections';
|
||||
if (form?.passwordError || form?.loginUsernameError) return 'access';
|
||||
if (form?.deleteError) return 'account';
|
||||
return 'identity';
|
||||
}
|
||||
function initialAvatarPanel(): AvatarPanel {
|
||||
return form?.avatarError ? 'upload' : 'presets';
|
||||
}
|
||||
let accountPanel = $state<AccountPanel>(initialAccountPanel());
|
||||
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
|
||||
function beginDiscordLink(event: MouseEvent) {
|
||||
if (discordLinkPending) { event.preventDefault(); return; }
|
||||
discordLinkPending = true;
|
||||
@@ -28,38 +41,68 @@
|
||||
</section>
|
||||
<aside class="account-console">
|
||||
<div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div>
|
||||
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
|
||||
<div class="avatar-picker" aria-label="SHD avatar presets">
|
||||
{#each data.avatarPresets as preset}
|
||||
<PendingForm action="?/selectAvatar" class="avatar-preset-form">
|
||||
<button class="avatar-preset" class:selected={user.avatarUrl === preset.url} name="preset" value={preset.id} aria-label={`Use ${preset.label} avatar`} aria-pressed={user.avatarUrl === preset.url}>
|
||||
<img src={preset.url} alt="" /><span>{preset.label}</span>
|
||||
</button>
|
||||
<div class="account-tabs" aria-label="Account controls" role="tablist">
|
||||
<button id="identity-tab" class:active={accountPanel === 'identity'} role="tab" aria-selected={accountPanel === 'identity'} aria-controls="identity-panel" onclick={() => accountPanel = 'identity'}>PROFILE</button>
|
||||
<button id="access-tab" class:active={accountPanel === 'access'} role="tab" aria-selected={accountPanel === 'access'} aria-controls="access-panel" onclick={() => accountPanel = 'access'}>ACCESS</button>
|
||||
<button id="connections-tab" class:active={accountPanel === 'connections'} role="tab" aria-selected={accountPanel === 'connections'} aria-controls="connections-panel" onclick={() => accountPanel = 'connections'}>LINKS</button>
|
||||
<button id="account-tab" class:active={accountPanel === 'account'} role="tab" aria-selected={accountPanel === 'account'} aria-controls="account-panel" onclick={() => accountPanel = 'account'}>ACCOUNT</button>
|
||||
</div>
|
||||
|
||||
{#if accountPanel === 'identity'}
|
||||
<div class="account-panel" id="identity-panel" role="tabpanel" aria-labelledby="identity-tab">
|
||||
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
|
||||
<div class="account-subtabs" role="tablist" aria-label="Avatar source">
|
||||
<button class:active={avatarPanel === 'presets'} role="tab" aria-selected={avatarPanel === 'presets'} onclick={() => avatarPanel = 'presets'}>SHD PRESETS</button>
|
||||
<button class:active={avatarPanel === 'upload'} role="tab" aria-selected={avatarPanel === 'upload'} onclick={() => avatarPanel = 'upload'}>CUSTOM UPLOAD</button>
|
||||
</div>
|
||||
{#if avatarPanel === 'presets'}
|
||||
<div class="avatar-picker" aria-label="SHD avatar presets">
|
||||
{#each data.avatarPresets as preset}
|
||||
<PendingForm action="?/selectAvatar" class="avatar-preset-form">
|
||||
<button class="avatar-preset" class:selected={user.avatarUrl === preset.url} name="preset" value={preset.id} aria-label={`Use ${preset.label} avatar`} aria-pressed={user.avatarUrl === preset.url}>
|
||||
<img src={preset.url} alt="" /><span>{preset.label}</span>
|
||||
</button>
|
||||
</PendingForm>
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.avatarChanged}<p class="success">Avatar updated.</p>{/if}
|
||||
{:else}
|
||||
<PendingForm action="?/uploadAvatar" enctype="multipart/form-data" class="avatar-upload">
|
||||
<label class="field">CUSTOM AVATAR<input name="avatar" type="file" accept="image/jpeg,image/png,image/webp,image/avif" required /></label>
|
||||
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
|
||||
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
|
||||
{#if form?.avatarChanged}<p class="success">Avatar updated.</p>{/if}
|
||||
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
|
||||
</PendingForm>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr/>
|
||||
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
|
||||
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
|
||||
</div>
|
||||
{:else if accountPanel === 'access'}
|
||||
<div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab">
|
||||
<p class="eyebrow">PRIVATE CREDENTIAL</p><h2>LOGIN USERNAME</h2>
|
||||
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.username} autocomplete="username" required /></label><small class="identity-note">Used only to sign in and visible to you and authorized ISAC operators.</small>{#if form?.loginUsernameError}<p class="error">{form.loginUsernameError}</p>{/if}<button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
|
||||
<hr/><p class="eyebrow">SECURITY</p><h2>{data.hasPassword ? 'CHANGE PASSWORD' : 'SET PASSWORD'}</h2>
|
||||
<PendingForm action="?/changePassword">{#if data.hasPassword}<label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" autocomplete="current-password" required /></label>{/if}<label class="field">NEW PASSWORD<input name="newPassword" type="password" autocomplete="new-password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">{data.hasPassword ? 'UPDATE PASSWORD' : 'ADD PASSWORD SIGN-IN'} →</button></PendingForm>
|
||||
</div>
|
||||
{:else if accountPanel === 'connections'}
|
||||
<div class="account-panel" id="connections-panel" role="tabpanel" aria-labelledby="connections-tab">
|
||||
<p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2>
|
||||
{#if data.discordConnection}
|
||||
<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>{data.discordConnection.displayName ?? data.discordConnection.username ?? 'DISCORD USER'}</b><small>{data.discordConnection.username ? `@${data.discordConnection.username} · ` : ''}ID {data.discordConnection.id}</small></div></div><span class="connection-state">LINKED</span></div>
|
||||
<PendingForm action="?/unlinkDiscord" class="unlink-console"><label class="field">PASSWORD TO UNLINK<input name="password" type="password" autocomplete="current-password" disabled={!data.discordConnection.canUnlink} required={data.discordConnection.canUnlink}/></label>{#if !data.discordConnection.canUnlink}<p class="connection-warning">Discord is your only sign-in method. Set a password in Access before unlinking.</p>{/if}{#if form?.discordUnlinkError}<p class="error">{form.discordUnlinkError}</p>{/if}<button class="secondary console-action" disabled={!data.discordConnection.canUnlink}>UNLINK DISCORD</button></PendingForm>
|
||||
{:else}
|
||||
<div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div>
|
||||
{/if}
|
||||
</div>
|
||||
<PendingForm action="?/uploadAvatar" enctype="multipart/form-data" class="avatar-upload">
|
||||
<label class="field">CUSTOM AVATAR<input name="avatar" type="file" accept="image/jpeg,image/png,image/webp,image/avif" required /></label>
|
||||
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
|
||||
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
|
||||
{#if form?.avatarChanged}<p class="success">Avatar updated.</p>{/if}
|
||||
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
|
||||
</PendingForm>
|
||||
<hr/>
|
||||
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
|
||||
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
|
||||
<hr/><p class="eyebrow">PRIVATE CREDENTIAL</p><h2>LOGIN USERNAME</h2>
|
||||
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.username} autocomplete="username" required /></label><small class="identity-note">Used only to sign in and visible to you and authorized ISAC operators.</small>{#if form?.loginUsernameError}<p class="error">{form.loginUsernameError}</p>{/if}<button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
|
||||
<hr/><p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2>
|
||||
{#if data.discordConnection}
|
||||
<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>{data.discordConnection.displayName ?? data.discordConnection.username ?? 'DISCORD USER'}</b><small>{data.discordConnection.username ? `@${data.discordConnection.username} · ` : ''}ID {data.discordConnection.id}</small></div></div><span class="connection-state">LINKED</span></div>
|
||||
<PendingForm action="?/unlinkDiscord" class="unlink-console"><label class="field">PASSWORD TO UNLINK<input name="password" type="password" autocomplete="current-password" disabled={!data.discordConnection.canUnlink} required={data.discordConnection.canUnlink}/></label>{#if !data.discordConnection.canUnlink}<p class="connection-warning">Discord is your only sign-in method. Set a password below before unlinking.</p>{/if}{#if form?.discordUnlinkError}<p class="error">{form.discordUnlinkError}</p>{/if}<button class="secondary console-action" disabled={!data.discordConnection.canUnlink}>UNLINK DISCORD</button></PendingForm>
|
||||
{:else}
|
||||
<div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div>
|
||||
<div class="account-panel" id="account-panel" role="tabpanel" aria-labelledby="account-tab">
|
||||
<p class="eyebrow danger-eyebrow">ACCOUNT LIFECYCLE</p><h2>DELETE ACCOUNT</h2>
|
||||
<p class="account-retention">Your builds, guides, and comments remain attributed to a deleted agent. Your login username, display name, and Discord identity become available again.</p>
|
||||
<details class="danger-zone" open={Boolean(form?.deleteError)}><summary>OPEN DELETION CONTROLS</summary><PendingForm action="?/deleteAccount"><label class="field">TYPE {user.username} TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
|
||||
</div>
|
||||
{/if}
|
||||
<hr/><p class="eyebrow">SECURITY</p><h2>{data.hasPassword ? 'CHANGE PASSWORD' : 'SET PASSWORD'}</h2>
|
||||
<PendingForm action="?/changePassword">{#if data.hasPassword}<label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label>{/if}<label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">{data.hasPassword ? 'UPDATE PASSWORD' : 'ADD PASSWORD SIGN-IN'} →</button></PendingForm>
|
||||
<hr/><details class="danger-zone"><summary>DELETE ACCOUNT</summary><p>Your builds, guides, and comments remain attributed to a deleted agent. Your login username, display name, and Discord identity become available again.</p><PendingForm action="?/deleteAccount"><label class="field">TYPE {user.username} TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user