feat: expand profiles and weapon configuration
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||
import InfoPopover from '$lib/components/InfoPopover.svelte';
|
||||
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
account,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}: {
|
||||
account: any;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
} = $props();
|
||||
|
||||
type AccountPanel = 'identity' | 'access' | 'account';
|
||||
type AccessPanel = 'passkeys' | 'email' | 'local' | 'connections';
|
||||
type AvatarPanel = 'presets' | 'upload';
|
||||
function initialPanel(): AccountPanel {
|
||||
return (
|
||||
['identity', 'access', 'account'].includes(account.initialPanel)
|
||||
? account.initialPanel
|
||||
: 'identity'
|
||||
) as AccountPanel;
|
||||
}
|
||||
let accountPanel = $state<AccountPanel>(initialPanel());
|
||||
let accessPanel = $state<AccessPanel>('passkeys');
|
||||
let avatarPanel = $state<AvatarPanel>('presets');
|
||||
let avatarEditorOpen = $state(false);
|
||||
let passkeySupported = $state(true);
|
||||
let passkeyPending = $state(false);
|
||||
let passkeyName = $state('My passkey');
|
||||
let discordLinkPending = $state(false);
|
||||
let discordUnlinkConfirm = $state(false);
|
||||
|
||||
onMount(() => passkeySupported = browserSupportsWebAuthn());
|
||||
|
||||
async function registerPasskey() {
|
||||
if (passkeyPending) return;
|
||||
passkeyPending = true;
|
||||
try {
|
||||
const optionsRequest = await fetch('/auth/passkey/register/options', { method: 'POST' });
|
||||
if (!optionsRequest.ok) throw new Error('Could not start passkey registration.');
|
||||
const { options, challengeId } = await optionsRequest.json();
|
||||
const response = await startRegistration({ optionsJSON: options });
|
||||
const verification = await fetch('/auth/passkey/register/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ challengeId, response, name: passkeyName })
|
||||
});
|
||||
const result = await verification.json();
|
||||
if (!verification.ok || !result.verified) {
|
||||
throw new Error(result.message || 'Passkey registration failed.');
|
||||
}
|
||||
toast.success('Passkey registered', 'Your new sign-in method is ready.');
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
'Passkey not registered',
|
||||
error instanceof Error ? error.message : 'Passkey registration was cancelled.'
|
||||
);
|
||||
passkeyPending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="account-console owner-account-control">
|
||||
<div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div>
|
||||
<div class="account-tabs" aria-label="Account controls" role="tablist">
|
||||
<button class:active={accountPanel === 'identity'} role="tab" aria-selected={accountPanel === 'identity'} onclick={() => accountPanel = 'identity'}>PROFILE</button>
|
||||
<button class:active={accountPanel === 'access'} role="tab" aria-selected={accountPanel === 'access'} onclick={() => accountPanel = 'access'}>ACCESS</button>
|
||||
<button class:active={accountPanel === 'account'} role="tab" aria-selected={accountPanel === 'account'} onclick={() => accountPanel = 'account'}>ACCOUNT</button>
|
||||
</div>
|
||||
|
||||
{#if accountPanel === 'identity'}
|
||||
<div class="account-panel" role="tabpanel">
|
||||
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
|
||||
<div class="current-avatar">
|
||||
<img src={avatarUrl} alt={`${displayName}'s current avatar`}/>
|
||||
<div><small>CURRENT AVATAR</small><strong>{displayName}</strong></div>
|
||||
<button type="button" class="secondary avatar-edit-trigger" aria-expanded={avatarEditorOpen} aria-controls="avatar-editor" onclick={() => avatarEditorOpen = !avatarEditorOpen}>{avatarEditorOpen ? 'CLOSE' : 'EDIT AVATAR'}</button>
|
||||
</div>
|
||||
{#if avatarEditorOpen}
|
||||
<div id="avatar-editor" class="avatar-editor">
|
||||
<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 account.avatarPresets as preset}
|
||||
<PendingForm action="/account?/selectAvatar" class="avatar-preset-form">
|
||||
<button class="avatar-preset" class:selected={avatarUrl === preset.url} name="preset" value={preset.id} aria-label={`Use ${preset.label} avatar`} aria-pressed={avatarUrl === preset.url}>
|
||||
<img src={preset.url} alt=""/><span>{preset.label}</span>
|
||||
</button>
|
||||
</PendingForm>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<PendingForm action="/account?/uploadAvatar" enctype="multipart/form-data" class="avatar-upload">
|
||||
<ImageUploadInput name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" required emptyLabel="NO AVATAR SELECTED" browseLabel="CHOOSE AVATAR"/>
|
||||
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
|
||||
<ModerationDisclosure compact/>
|
||||
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
|
||||
</PendingForm>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<hr/>
|
||||
<div class="account-section-heading">
|
||||
<div><p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2></div>
|
||||
<InfoPopover label="Display-name requirements" title="DISPLAY-NAME REQUIREMENTS" text="Use a unique name with 2–40 visible characters on one line. Unicode characters and spaces are supported."/>
|
||||
</div>
|
||||
<PendingForm action="/account?/changeDisplayName">
|
||||
<label class="field"><span class="sr-only">PUBLIC DISPLAY NAME</span><input name="displayName" value={displayName} maxlength="40" required/></label>
|
||||
<button class="primary console-action">UPDATE DISPLAY NAME →</button>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{:else if accountPanel === 'access'}
|
||||
<div class="account-panel" role="tabpanel">
|
||||
<div class="access-subtabs" role="tablist" aria-label="Sign-in method controls">
|
||||
<button class:active={accessPanel === 'passkeys'} role="tab" aria-selected={accessPanel === 'passkeys'} onclick={() => accessPanel = 'passkeys'}><span>PASSKEYS</span><b>{account.passkeys.length}</b></button>
|
||||
<button class:active={accessPanel === 'email'} role="tab" aria-selected={accessPanel === 'email'} onclick={() => accessPanel = 'email'}><span>EMAIL</span><b>{account.emailIdentity ? 1 : 0}</b></button>
|
||||
<button class:active={accessPanel === 'local'} role="tab" aria-selected={accessPanel === 'local'} onclick={() => accessPanel = 'local'}><span>PASSWORD</span><b>{account.hasLocalCredentials ? 1 : 0}</b></button>
|
||||
<button class:active={accessPanel === 'connections'} role="tab" aria-selected={accessPanel === 'connections'} onclick={() => accessPanel = 'connections'}><span>LINKS</span><b>{account.discordConnection ? 1 : 0}</b></button>
|
||||
</div>
|
||||
{#if accessPanel === 'passkeys'}
|
||||
<p class="eyebrow">PREFERRED ACCESS</p><h2>PASSKEYS</h2>
|
||||
{#if account.passkeys.length}
|
||||
<div class="passkey-list">
|
||||
{#each account.passkeys as passkey}
|
||||
<div class="passkey-card"><div><b>{passkey.name}</b><small>{passkey.backedUp ? 'SYNCED PASSKEY' : 'DEVICE PASSKEY'}</small></div><PendingForm action="/account?/removePasskey"><input type="hidden" name="passkeyId" value={passkey.id}/><button class="passkey-remove" aria-label={`Remove ${passkey.name}`} disabled={account.authenticationMethods.total <= 1}>REMOVE</button></PendingForm></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<p class="account-method-empty">NO PASSKEYS REGISTERED</p>{/if}
|
||||
<label class="field">PASSKEY NAME<input bind:value={passkeyName} maxlength="60" placeholder="e.g. Windows Hello"/></label>
|
||||
<button class="primary console-action" disabled={!passkeySupported || passkeyPending || !passkeyName.trim()} aria-busy={passkeyPending} onclick={registerPasskey}>{passkeyPending ? 'REGISTERING…' : 'ADD A PASSKEY →'}</button>
|
||||
{:else if accessPanel === 'email'}
|
||||
<p class="eyebrow">PASSWORDLESS RECOVERY</p><h2>MAGIC LINK EMAIL</h2>
|
||||
{#if account.emailIdentity}
|
||||
<div class="verified-email"><div><b>{account.emailIdentity.email}</b><small>VERIFIED EMAIL IDENTITY</small></div><span>VERIFIED</span></div>
|
||||
<PendingForm action="/account?/removeEmailIdentity"><button class="secondary console-action" disabled={account.authenticationMethods.total <= 1}>REMOVE MAGIC-LINK EMAIL</button></PendingForm>
|
||||
{/if}
|
||||
<PendingForm action="/account?/requestEmailVerification">
|
||||
<label class="field">{account.emailIdentity ? 'CHANGE VERIFIED EMAIL' : 'EMAIL ADDRESS'}<input name="email" type="email" value={account.emailIdentity?.email ?? ''} autocomplete="email" required/></label>
|
||||
<button class="secondary console-action" disabled={!account.emailDeliveryConfigured}>SEND VERIFICATION LINK →</button>
|
||||
</PendingForm>
|
||||
{:else if accessPanel === 'local'}
|
||||
<p class="eyebrow">OPTIONAL FALLBACK</p><h2>USERNAME & PASSWORD</h2>
|
||||
{#if account.hasLocalCredentials}
|
||||
<PendingForm action="/account?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={account.loginUsername ?? ''} autocomplete="username" required/></label><button class="secondary console-action">UPDATE LOGIN USERNAME →</button></PendingForm>
|
||||
<hr/>
|
||||
<PendingForm action="/account?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" autocomplete="current-password" required/></label><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><button class="primary console-action">UPDATE PASSWORD →</button></PendingForm>
|
||||
<details class="method-removal"><summary>REMOVE USERNAME & PASSWORD</summary><PendingForm action="/account?/removeLocalCredentials"><label class="field">CURRENT PASSWORD<input name="password" type="password" autocomplete="current-password" required/></label><button class="danger console-action" disabled={account.authenticationMethods.total <= 1}>REMOVE PASSWORD SIGN-IN</button></PendingForm></details>
|
||||
{:else}
|
||||
<p class="account-method-empty">USERNAME AND PASSWORD SIGN-IN IS NOT CONFIGURED</p>
|
||||
<PendingForm action="/account?/addLocalCredentials"><label class="field">PRIVATE LOGIN USERNAME<input name="username" autocomplete="username" required/></label><label class="field">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><button class="secondary console-action">ADD PASSWORD FALLBACK →</button></PendingForm>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN PROVIDERS</h2>
|
||||
{#if account.discordConnection}
|
||||
<div class="connection-card discord-connection linked-connection provider-connection-card">
|
||||
<div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{account.discordConnection.displayName ?? account.discordConnection.username ?? 'DISCORD USER'}</b><small>{account.discordConnection.username ? `@${account.discordConnection.username}` : `ID ${account.discordConnection.id}`}</small></div></div>
|
||||
<button class="connection-state provider-unlink-trigger" disabled={!account.discordConnection.canUnlink} onclick={() => discordUnlinkConfirm = true}>UNLINK</button>
|
||||
{#if discordUnlinkConfirm}<div class="provider-unlink-confirm"><b>UNLINK DISCORD?</b><div><button class="secondary" onclick={() => discordUnlinkConfirm = false}>CANCEL</button><PendingForm action="/account?/unlinkDiscord"><button class="primary">CONFIRM</button></PendingForm></div></div>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={() => discordLinkPending = true}><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><span>Link Discord</span></a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="account-panel" role="tabpanel">
|
||||
<p class="eyebrow danger-eyebrow">ACCOUNT LIFECYCLE</p><h2>DELETE ACCOUNT</h2>
|
||||
<p class="account-retention">Your public contributions remain attributed to a deleted Agent.</p>
|
||||
<details class="danger-zone"><summary>OPEN DELETION CONTROLS</summary><PendingForm action="/account?/deleteAccount"><label class="field">TYPE DELETE TO CONFIRM<input name="confirmation" autocomplete="off" required/></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password"/></label><label class="deletion-purge-option"><input name="purgeSecurityData" type="checkbox"/><span><b>PURGE SECURITY HISTORY NOW</b><small>Delete retained login events and device metadata instead of waiting for normal retention cleanup.</small></span></label><button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}
|
||||
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}}
|
||||
</style>
|
||||
@@ -1,29 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { agentProfileHref } from '$lib/agents';
|
||||
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
|
||||
|
||||
let {
|
||||
agent,
|
||||
size = 'default',
|
||||
meta = '',
|
||||
showId = false,
|
||||
linked = true
|
||||
linked = true,
|
||||
roles = null
|
||||
}: {
|
||||
agent: { id: string; displayName: string; avatarUrl: string; publicSlug?: string | null };
|
||||
size?: 'compact' | 'default' | 'large';
|
||||
meta?: string;
|
||||
showId?: boolean;
|
||||
linked?: boolean;
|
||||
roles?: string[] | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if linked}
|
||||
{#if linked && roles !== null}
|
||||
<div class="agent-profile-identity" class:compact={size === 'compact'} class:large={size === 'large'}>
|
||||
<img src={agent.avatarUrl} alt="" />
|
||||
<div class="agent-profile-content">
|
||||
<div class="agent-profile-name">
|
||||
<a href={agentProfileHref(agent)}>{agent.displayName}</a>
|
||||
<RoleBadgeList {roles} compact={size !== 'large'} />
|
||||
</div>
|
||||
{#if meta}<small>{meta}</small>{:else if showId}<small>AGENT {agent.id.slice(0,8).toUpperCase()}</small>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if linked}
|
||||
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={agentProfileHref(agent)}>
|
||||
<img src={agent.avatarUrl} alt="" />
|
||||
<span>
|
||||
<strong>{agent.displayName}</strong>
|
||||
{#if meta}<small>{meta}</small>{:else if showId}<small>AGENT {agent.id.slice(0,8).toUpperCase()}</small>{/if}
|
||||
</span>
|
||||
<i aria-hidden="true">›</i>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="agent-identity unavailable" class:compact={size === 'compact'} class:large={size === 'large'}>
|
||||
@@ -33,14 +46,15 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.agent-identity{min-width:0;display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:9px;color:var(--shd-text)}
|
||||
.agent-identity img{width:38px;height:38px;border:1px solid var(--shd-orange);border-radius:50%;background:var(--shd-surface);object-fit:cover}
|
||||
.agent-identity{min-width:0;display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:9px;color:var(--shd-text)}
|
||||
.agent-identity img{width:44px;height:44px;border:2px solid var(--shd-orange);border-radius:50%;background:var(--shd-surface);object-fit:cover}
|
||||
.agent-identity span{min-width:0;display:grid;gap:3px}.agent-identity strong,.agent-identity small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.agent-identity strong{font-size:var(--shd-type-supporting)}.agent-identity small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.agent-identity i{color:var(--shd-text-dim);font-size:18px;font-style:normal}.agent-identity:hover strong,.agent-identity:focus-visible strong,.agent-identity:hover i,.agent-identity:focus-visible i{color:var(--shd-orange)}
|
||||
.agent-identity.compact{grid-template-columns:30px minmax(0,1fr) auto;gap:7px}.agent-identity.compact img{width:30px;height:30px}.agent-identity.compact strong{font-size:var(--shd-type-telemetry)}
|
||||
.agent-identity.large{grid-template-columns:74px minmax(0,1fr) auto;gap:15px}.agent-identity.large img{width:74px;height:74px;box-shadow:0 0 0 5px var(--shd-bg),0 0 0 6px color-mix(in srgb,var(--shd-orange) 35%,transparent)}.agent-identity.large strong{font-size:clamp(1.5rem,3vw,2.6rem);line-height:1}.agent-identity.large small{font-size:var(--shd-type-supporting)}
|
||||
.agent-identity.unavailable{grid-template-columns:38px minmax(0,1fr)}.agent-identity.unavailable strong{color:var(--shd-text-muted)}
|
||||
.agent-identity:hover strong,.agent-identity:focus-visible strong{color:var(--shd-orange)}
|
||||
.agent-identity.compact{grid-template-columns:30px minmax(0,1fr);gap:7px}.agent-identity.compact img{width:30px;height:30px}.agent-identity.compact strong{font-size:var(--shd-type-telemetry)}
|
||||
.agent-identity.large{grid-template-columns:74px minmax(0,1fr);gap:15px}.agent-identity.large img,.agent-profile-identity.large>img{width:74px;height:74px;box-shadow:0 0 0 5px var(--shd-bg),0 0 0 6px var(--shd-orange)}.agent-identity.large strong{font-size:clamp(1.5rem,3vw,2.6rem);line-height:1}.agent-identity.large small{font-size:var(--shd-type-supporting)}
|
||||
.agent-profile-identity{min-width:0;display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:9px}.agent-profile-identity>img{width:44px;height:44px;border:2px solid var(--shd-orange);border-radius:50%;background:var(--shd-surface);object-fit:cover}.agent-profile-identity.compact{grid-template-columns:30px minmax(0,1fr);gap:7px}.agent-profile-identity.compact>img{width:30px;height:30px}.agent-profile-identity.large{grid-template-columns:74px minmax(0,1fr);gap:15px}.agent-profile-content{min-width:0;display:grid;gap:3px}.agent-profile-content>small{overflow:hidden;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;text-overflow:ellipsis;white-space:nowrap}.agent-profile-name{min-width:0;display:flex;align-items:center;flex-wrap:wrap;gap:5px 8px}.agent-profile-name>a{min-width:0;overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);font-weight:700;line-height:1;text-overflow:ellipsis;white-space:nowrap}.agent-profile-identity.compact .agent-profile-name>a{font-size:var(--shd-type-telemetry)}.agent-profile-identity.large .agent-profile-name{gap:8px 12px}.agent-profile-identity.large .agent-profile-name>a{font-size:clamp(1.5rem,3vw,2.6rem)}.agent-profile-name>a:hover,.agent-profile-name>a:focus-visible{color:var(--shd-orange)}
|
||||
.agent-identity.unavailable{grid-template-columns:44px minmax(0,1fr)}.agent-identity.unavailable strong{color:var(--shd-text-muted)}
|
||||
.agent-identity.unavailable.compact{grid-template-columns:30px minmax(0,1fr)}.agent-identity.unavailable.large{grid-template-columns:74px minmax(0,1fr)}
|
||||
@media(max-width:600px){.agent-identity{min-height:44px}.agent-identity.large{grid-template-columns:58px minmax(0,1fr) auto}.agent-identity.large img{width:58px;height:58px}.agent-identity.large strong{font-size:1.6rem}.agent-identity.unavailable.large{grid-template-columns:58px minmax(0,1fr)}}
|
||||
@media(max-width:600px){.agent-identity,.agent-profile-identity{min-height:44px}.agent-identity.large,.agent-profile-identity.large{grid-template-columns:58px minmax(0,1fr)}.agent-identity.large img,.agent-profile-identity.large>img{width:58px;height:58px}.agent-identity.large strong,.agent-profile-identity.large .agent-profile-name>a{font-size:1.6rem}.agent-profile-identity.large .agent-profile-name{align-items:flex-start;flex-direction:column;gap:7px}.agent-identity.unavailable.large{grid-template-columns:58px minmax(0,1fr)}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value = $bindable(''),
|
||||
name,
|
||||
label,
|
||||
maxLength = 2000,
|
||||
rows = 4,
|
||||
required = false,
|
||||
id = `${name}-comment-composer`
|
||||
}: {
|
||||
value?: string;
|
||||
name: string;
|
||||
label: string;
|
||||
maxLength?: number;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
const remaining = $derived(maxLength - value.length);
|
||||
</script>
|
||||
|
||||
<div class="comment-composer">
|
||||
<label for={id}>{label}</label>
|
||||
<textarea
|
||||
bind:value
|
||||
{id}
|
||||
{name}
|
||||
{rows}
|
||||
{required}
|
||||
maxlength={maxLength}
|
||||
placeholder="Write a message…"
|
||||
spellcheck="true"
|
||||
></textarea>
|
||||
<footer>
|
||||
<span>MARKDOWN SUPPORTED</span>
|
||||
<small class:near-limit={remaining < 200}>{remaining.toLocaleString()} REMAINING</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comment-composer{display:grid}
|
||||
.comment-composer label{margin-bottom:7px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.comment-composer textarea{width:100%;min-height:112px;margin:0;padding:12px;border:1px solid var(--shd-border-strong);border-radius:0;background:var(--shd-bg);color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);line-height:1.55;resize:vertical}
|
||||
.comment-composer textarea::placeholder{color:var(--shd-text-dim)}
|
||||
.comment-composer textarea:hover{border-color:var(--shd-text-dim)}
|
||||
.comment-composer textarea:focus-visible{outline:1px solid var(--shd-orange);outline-offset:1px;border-color:var(--shd-orange)}
|
||||
.comment-composer footer{display:flex;justify-content:space-between;gap:12px;padding-top:6px;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}
|
||||
.comment-composer small{font:inherit}.comment-composer small.near-limit{color:var(--shd-orange)}
|
||||
@media(max-width:650px){.comment-composer textarea{min-height:128px}.comment-composer footer{align-items:flex-end;flex-direction:column-reverse;gap:3px}}
|
||||
</style>
|
||||
@@ -5,12 +5,16 @@
|
||||
contentType,
|
||||
contentId,
|
||||
label = 'REPORT',
|
||||
result
|
||||
result,
|
||||
plain = false,
|
||||
menuItem = false
|
||||
}: {
|
||||
contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar';
|
||||
contentId: string;
|
||||
label?: string;
|
||||
result?: Record<string, unknown> | null;
|
||||
plain?: boolean;
|
||||
menuItem?: boolean;
|
||||
} = $props();
|
||||
|
||||
const categories = [
|
||||
@@ -27,37 +31,45 @@
|
||||
String(result?.userReportContentId ?? '') === contentId
|
||||
&& String(result?.userReportContentType ?? '') === contentType
|
||||
);
|
||||
let reportOpen = $state(false);
|
||||
|
||||
function closeReport() {
|
||||
reportOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<details class="content-report">
|
||||
<summary>{label}</summary>
|
||||
<div class="content-report-panel">
|
||||
<strong>REQUEST OVERSIGHT REVIEW</strong>
|
||||
<p>Send this content to Community Oversight. A report does not remove it automatically.</p>
|
||||
<PendingForm action="?/reportContent" toast={false}>
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="contentType" value={contentType}/>
|
||||
<input type="hidden" name="contentId" value={contentId}/>
|
||||
<label class="field">CONCERN
|
||||
<select name="category" required>
|
||||
{#each categories as category}<option value={category.value}>{category.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">DETAILS — OPTIONAL
|
||||
<textarea name="details" maxlength="500" rows="3" placeholder="Give Oversight useful context without repeating sensitive information."></textarea>
|
||||
</label>
|
||||
{#if matchesResult && result?.userReportError}<p class="error">{String(result.userReportError)}</p>{/if}
|
||||
{#if matchesResult && result?.userReportSaved}<p class="success">Report sent to Community Oversight.</p>{/if}
|
||||
<div class="content-report-actions">
|
||||
<button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SENDING…' : 'SEND REPORT →'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</div>
|
||||
<details bind:open={reportOpen} class="content-report" class:plain class:menu-item={menuItem}>
|
||||
<summary role={menuItem ? 'button' : undefined}>{label}</summary>
|
||||
{#if reportOpen}
|
||||
<div class="content-report-panel">
|
||||
<strong>REQUEST OVERSIGHT REVIEW</strong>
|
||||
<p>Send this content to Community Oversight. A report does not remove it automatically.</p>
|
||||
<PendingForm action="?/reportContent" toast={false}>
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="contentType" value={contentType}/>
|
||||
<input type="hidden" name="contentId" value={contentId}/>
|
||||
<label class="field">CONCERN
|
||||
<select name="category" required>
|
||||
{#each categories as category}<option value={category.value}>{category.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">DETAILS — OPTIONAL
|
||||
<textarea name="details" maxlength="500" rows="3" placeholder="Give Oversight useful context without repeating sensitive information."></textarea>
|
||||
</label>
|
||||
{#if matchesResult && result?.userReportError}<p class="error">{String(result.userReportError)}</p>{/if}
|
||||
{#if matchesResult && result?.userReportSaved}<p class="success">Report sent to Community Oversight.</p>{/if}
|
||||
<div class="content-report-actions">
|
||||
<button type="button" class="secondary" onclick={closeReport}>CANCEL</button>
|
||||
<button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SENDING…' : 'SEND REPORT →'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.content-report{position:relative}.content-report summary{list-style:none;min-height:32px;display:inline-flex;align-items:center;padding:0 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.content-report summary::-webkit-details-marker{display:none}.content-report summary:hover,.content-report summary:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)}.content-report[open] summary{border-color:var(--shd-orange);color:var(--shd-orange)}.content-report-panel{position:absolute;z-index:8;right:0;width:min(380px,calc(100vw - 32px));display:grid;gap:8px;padding:10px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:0 18px 46px #000a}.content-report-panel>strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.content-report-panel>p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.content-report-panel :global(form fieldset){display:grid;gap:8px}.content-report-panel :global(.field){margin:0}.content-report-panel textarea{min-height:80px}.content-report-actions{display:flex;justify-content:flex-end}.content-report-actions button{min-height:40px}
|
||||
@media(max-width:650px){.content-report-panel{position:fixed;right:16px;bottom:16px;left:16px;width:auto}.content-report summary{min-height:44px}.content-report-actions button{width:100%;min-height:44px}}
|
||||
.content-report{position:relative}.content-report summary{list-style:none;min-height:32px;display:inline-flex;align-items:center;padding:0 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.content-report summary::-webkit-details-marker{display:none}.content-report summary:hover,.content-report summary:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)}.content-report[open] summary{border-color:var(--shd-orange);color:var(--shd-orange)}.content-report.plain summary{min-height:32px;padding:0;border:0;background:transparent}.content-report.plain summary:hover,.content-report.plain summary:focus-visible{color:var(--shd-danger);text-decoration:underline;text-underline-offset:3px}.content-report.plain[open] summary{color:var(--shd-orange)}.content-report.menu-item summary{border:0}.content-report.menu-item summary:hover,.content-report.menu-item summary:focus-visible{color:var(--shd-orange);text-decoration:none}.content-report-panel{position:absolute;z-index:8;top:calc(100% + 6px);right:0;width:min(380px,calc(100vw - 32px));display:grid;gap:8px;padding:10px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:0 18px 46px #000a}.content-report.plain:not(.menu-item) .content-report-panel{right:auto;left:0}.content-report-panel>strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.content-report-panel>p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.content-report-panel :global(form fieldset){display:grid;gap:8px}.content-report-panel :global(.field){margin:0}.content-report-panel textarea{min-height:80px}.content-report-actions{display:flex;justify-content:flex-end;gap:7px}.content-report-actions button{min-height:40px}
|
||||
@media(max-width:650px){.content-report-panel{position:fixed;top:auto;right:16px;bottom:16px;left:16px;width:auto}.content-report summary{min-height:44px}.content-report-actions{display:grid;grid-template-columns:1fr 1fr}.content-report-actions button{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.content-report summary{transition:none}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
label = 'More information',
|
||||
title,
|
||||
text
|
||||
}: {
|
||||
label?: string;
|
||||
title: string;
|
||||
text: string;
|
||||
} = $props();
|
||||
|
||||
let root: HTMLDetailsElement;
|
||||
let open = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
if (!root.contains(event.target as Node)) open = false;
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
open = false;
|
||||
root.querySelector<HTMLElement>('summary')?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOutside);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<details bind:this={root} bind:open class="info-popover">
|
||||
<summary aria-label={label}>i</summary>
|
||||
<div class="info-popover-panel" role="note">
|
||||
<strong>{title}</strong>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.info-popover{position:relative}.info-popover summary{width:28px;height:28px;display:grid;place-items:center;list-style:none;border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:13px;font-style:italic;font-weight:700;cursor:pointer}.info-popover summary::-webkit-details-marker{display:none}.info-popover summary:hover,.info-popover summary:focus-visible,.info-popover[open] summary{border-color:var(--shd-orange);background:var(--shd-surface-active);color:var(--shd-orange)}.info-popover-panel{position:absolute;z-index:9;top:calc(100% + 6px);right:0;width:min(290px,calc(100vw - 32px));display:grid;gap:5px;padding:10px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:8px 12px 28px #0009}.info-popover-panel strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.info-popover-panel p{margin:0;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.5 var(--shd-font-editorial)}
|
||||
@media(max-width:600px){.info-popover summary{width:44px;height:44px}.info-popover-panel{position:fixed;top:auto;right:12px;bottom:12px;left:12px;width:auto}}
|
||||
@media(prefers-reduced-motion:reduce){.info-popover summary{transition:none}}
|
||||
</style>
|
||||
@@ -27,6 +27,8 @@
|
||||
.markdown-content :global(li+li){margin-top:.3em}
|
||||
.markdown-content :global(a){color:var(--shd-orange);font-family:var(--shd-font-sans);font-weight:700}
|
||||
.markdown-content :global(a:hover),.markdown-content :global(a:focus-visible){color:var(--shd-orange-hover)}
|
||||
.markdown-content :global(strong){font-weight:700}
|
||||
.markdown-content :global(em){font-style:italic}
|
||||
.markdown-content :global(blockquote){padding:.2em 0 .2em 1em;border-left:2px solid var(--shd-orange);color:var(--shd-text-muted)}
|
||||
.markdown-content :global(code){padding:2px 4px;border:1px solid var(--shd-border);background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-mono);font-size:.9em}
|
||||
.markdown-content :global(:first-child){margin-top:0}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<aside class="moderation-disclosure" aria-label="Automated safety review">
|
||||
<script lang="ts">
|
||||
let { compact = false }: { compact?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<aside class="moderation-disclosure" class:compact aria-label="Automated safety review">
|
||||
<span aria-hidden="true">i</span>
|
||||
<p>
|
||||
This submission may receive automated safety review.
|
||||
@@ -13,5 +17,6 @@
|
||||
.moderation-disclosure a{display:inline-block;margin-left:4px;color:var(--shd-orange);font-weight:900;letter-spacing:.4px}
|
||||
.moderation-disclosure a:hover{text-decoration:underline}
|
||||
.moderation-disclosure a:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}
|
||||
.moderation-disclosure.compact{align-items:center;margin:0;padding:0;border:0;background:transparent}.moderation-disclosure.compact>span{display:none}.moderation-disclosure.compact p{color:var(--shd-text-dim)}.moderation-disclosure.compact a{color:var(--shd-text-muted)}
|
||||
@media(max-width:650px){.moderation-disclosure{padding:10px}.moderation-disclosure a{display:block;min-height:44px;margin:4px 0 0;padding-top:8px}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
label,
|
||||
open = $bindable(false),
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
open?: boolean;
|
||||
children: Snippet<[() => void]>;
|
||||
} = $props();
|
||||
|
||||
let root: HTMLDivElement;
|
||||
let trigger: HTMLButtonElement;
|
||||
const close = () => {
|
||||
open = false;
|
||||
trigger?.focus();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
if (!root.contains(event.target as Node)) open = false;
|
||||
};
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointer);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
});
|
||||
|
||||
async function toggle() {
|
||||
open = !open;
|
||||
if (!open) return;
|
||||
await tick();
|
||||
root.querySelector<HTMLElement>('.overflow-menu-panel button, .overflow-menu-panel summary')?.focus();
|
||||
}
|
||||
|
||||
function navigate(event: KeyboardEvent) {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const items = [...root.querySelectorAll<HTMLElement>(
|
||||
'.overflow-menu-panel button:not([disabled]), .overflow-menu-panel summary'
|
||||
)].filter((item) => item.getClientRects().length > 0);
|
||||
if (!items.length) return;
|
||||
event.preventDefault();
|
||||
const current = items.indexOf(document.activeElement as HTMLElement);
|
||||
const next = event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? items.length - 1
|
||||
: event.key === 'ArrowDown'
|
||||
? (current + 1 + items.length) % items.length
|
||||
: (current - 1 + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-menu" bind:this={root}>
|
||||
<button
|
||||
bind:this={trigger}
|
||||
type="button"
|
||||
class="overflow-menu-trigger"
|
||||
aria-label={label}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onclick={toggle}
|
||||
>
|
||||
<span aria-hidden="true">•••</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="overflow-menu-panel" role="toolbar" aria-label={label} tabindex="-1" onkeydown={navigate}>
|
||||
{@render children(close)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overflow-menu{position:relative}.overflow-menu-trigger{width:44px;height:44px;display:grid;place-items:center;padding:0;border:1px solid transparent;background:transparent;color:var(--shd-text-muted);font-size:15px;font-weight:900;letter-spacing:2px;cursor:pointer}.overflow-menu-trigger:hover,.overflow-menu-trigger:focus-visible,.overflow-menu-trigger[aria-expanded=true]{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel{position:absolute;z-index:7;top:calc(100% + 4px);left:0;width:max-content;min-width:210px;display:grid;padding:4px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:8px 12px 28px #0009}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){width:100%;min-height:40px;display:flex;align-items:center;justify-content:flex-start;padding:0 10px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;text-align:left;cursor:pointer}.overflow-menu-panel :global(form>fieldset>button:hover),.overflow-menu-panel :global(form>fieldset>button:focus-visible),.overflow-menu-panel :global(details>summary:hover),.overflow-menu-panel :global(details>summary:focus-visible),.overflow-menu-panel :global(>button:hover),.overflow-menu-panel :global(>button:focus-visible){background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel :global(>button.danger:hover),.overflow-menu-panel :global(>button.danger:focus-visible){color:var(--shd-danger)}
|
||||
@media(max-width:650px){.overflow-menu-panel{position:fixed;z-index:12;top:auto;right:12px;bottom:12px;left:12px;width:auto;padding:6px}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){min-height:48px}}
|
||||
@media(prefers-reduced-motion:reduce){.overflow-menu-trigger{transition:none}}
|
||||
</style>
|
||||
@@ -1,11 +1,22 @@
|
||||
<script lang="ts">
|
||||
type Item = { id: string; name: string; unit?: string; pending?: boolean; proposerName?: string };
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit?: string;
|
||||
pending?: boolean;
|
||||
proposerName?: string;
|
||||
iconUrl?: string | null;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
let {
|
||||
items,
|
||||
selected = $bindable<string[]>([]),
|
||||
value = $bindable(''),
|
||||
mode = 'multiple',
|
||||
compact = false,
|
||||
presentation = 'list',
|
||||
excludedIds = [],
|
||||
fieldName = '',
|
||||
emptyLabel = 'NO MATCHING RECORDS',
|
||||
@@ -20,6 +31,7 @@
|
||||
value?: string;
|
||||
mode?: 'multiple' | 'single';
|
||||
compact?: boolean;
|
||||
presentation?: 'list' | 'visual-grid' | 'toggle-grid';
|
||||
excludedIds?: string[];
|
||||
fieldName?: string;
|
||||
emptyLabel?: string;
|
||||
@@ -37,7 +49,7 @@
|
||||
!excludedIds.includes(item.id)
|
||||
&& (!normalizedQuery || `${item.name} ${item.unit ?? ''}`.toLocaleLowerCase().includes(normalizedQuery))
|
||||
));
|
||||
const visible = $derived(available.slice(0, 30));
|
||||
const visible = $derived(presentation === 'list' ? available.slice(0, 30) : available);
|
||||
const selectedItems = $derived(selectedIds.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item)));
|
||||
const unitLabel = (item: Item) => item.unit === undefined ? '' : item.unit || 'FLAT';
|
||||
const accessibleName = (item: Item) => unitLabel(item) ? `${item.name}, unit ${unitLabel(item)}` : item.name;
|
||||
@@ -54,26 +66,36 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="assignment-picker" class:compact class:open onfocusin={() => { if (compact) open = true; }} onfocusout={(event) => {
|
||||
<div class="assignment-picker" class:compact class:open class:visual-grid={presentation === 'visual-grid'} class:toggle-grid={presentation === 'toggle-grid'} onfocusin={() => { if (compact) open = true; }} onfocusout={(event) => {
|
||||
if (compact && !event.currentTarget.contains(event.relatedTarget as Node | null)) open = false;
|
||||
}}>
|
||||
{#if fieldName}{#each selectedIds as id}<input type="hidden" name={fieldName} value={id}/>{/each}{/if}
|
||||
<div class="assignment-selected">
|
||||
<div class="assignment-summary"><span>{mode === 'single' ? 'SELECTED' : 'ASSIGNED'}</span><b>{selectedIds.length}</b></div>
|
||||
<div class="assignment-chips">
|
||||
{#each selectedItems as item}
|
||||
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}><span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b></button>
|
||||
{:else}
|
||||
<small>{emptySelectionLabel}</small>
|
||||
{/each}
|
||||
{#if presentation === 'list'}
|
||||
<div class="assignment-selected">
|
||||
<div class="assignment-summary"><span>{mode === 'single' ? 'SELECTED' : 'ASSIGNED'}</span><b>{selectedIds.length}</b></div>
|
||||
<div class="assignment-chips">
|
||||
{#each selectedItems as item}
|
||||
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}>
|
||||
{#if item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className="assignment-item-icon assignment-chip-icon"/>{/if}
|
||||
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b>
|
||||
</button>
|
||||
{:else}
|
||||
<small>{emptySelectionLabel}</small>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="assignment-search"><span>{searchLabel}</span><input type="search" bind:value={query} placeholder={searchPlaceholder} autocomplete="off"/></label>
|
||||
<div class="assignment-results-head"><span>{available.length} MATCHES</span>{#if available.length > visible.length}<b>SHOWING FIRST {visible.length}</b>{/if}</div>
|
||||
<label class="assignment-search"><span>{searchLabel}</span><input type="search" bind:value={query} placeholder={searchPlaceholder} autocomplete="off"/></label>
|
||||
<div class="assignment-results-head"><span>{available.length} MATCHES</span>{#if available.length > visible.length}<b>SHOWING FIRST {visible.length}</b>{/if}</div>
|
||||
{/if}
|
||||
<div class="assignment-results" role="list">
|
||||
{#each visible as item}
|
||||
<button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} aria-pressed={selectedIds.includes(item.id)} onclick={() => toggle(item.id)}>
|
||||
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
|
||||
<button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} class:with-icon={Boolean(item.showIcon || item.iconUrl)} class:visual-grid-option={presentation === 'visual-grid'} class:toggle-grid-option={presentation === 'toggle-grid'} aria-pressed={selectedIds.includes(item.id)} aria-label={presentation !== 'list' ? `${accessibleName(item)}, ${selectedIds.includes(item.id) ? 'assigned' : 'not assigned'}` : undefined} data-tooltip={presentation === 'visual-grid' ? item.name : undefined} onclick={() => toggle(item.id)}>
|
||||
{#if presentation === 'visual-grid' || item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className={`assignment-item-icon ${presentation === 'visual-grid' ? 'assignment-grid-icon' : 'assignment-result-icon'}`}/>{/if}
|
||||
{#if presentation === 'toggle-grid'}
|
||||
<span>{item.name}</span>
|
||||
{:else if presentation === 'list'}
|
||||
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<p>{emptyLabel}</p>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AVATAR_PRESETS } from '$lib/server/avatars';
|
||||
import { emailDeliveryConfigured } from '$lib/server/auth/mail';
|
||||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export async function loadAccountControlData(
|
||||
database: Database,
|
||||
userId: string,
|
||||
requestedPanel: string | null = null
|
||||
) {
|
||||
const [connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] =
|
||||
await Promise.all([
|
||||
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
|
||||
discord_profiles.username AS discord_username,discord_profiles.display_name,
|
||||
discord_profiles.avatar_hash
|
||||
FROM users
|
||||
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
||||
WHERE users.id=?`)).get(userId),
|
||||
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(userId),
|
||||
(await database.prepare(`SELECT id,name,device_type,backed_up,last_used_at,created_at
|
||||
FROM passkeys WHERE user_id=? ORDER BY created_at DESC`)).all(userId),
|
||||
getAuthenticationMethods(database, userId),
|
||||
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
|
||||
n.created_at,n.read_at
|
||||
FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL
|
||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
|
||||
ORDER BY n.created_at DESC LIMIT 5`)).all(userId),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL
|
||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId)
|
||||
]);
|
||||
return {
|
||||
initialPanel: ['identity', 'access', 'account'].includes(String(requestedPanel))
|
||||
? String(requestedPanel)
|
||||
: 'identity',
|
||||
loginUsername: connection?.login_username ? String(connection.login_username) : null,
|
||||
hasLocalCredentials: typeof connection?.password_hash === 'string'
|
||||
&& typeof connection?.login_username === 'string',
|
||||
discordConnection: connection?.discord_id ? {
|
||||
id: String(connection.discord_id),
|
||||
username: connection.discord_username ? String(connection.discord_username) : null,
|
||||
displayName: connection.display_name ? String(connection.display_name) : null,
|
||||
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
|
||||
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
|
||||
} : null,
|
||||
emailIdentity: emailIdentity ? {
|
||||
email: String(emailIdentity.email),
|
||||
verifiedAt: String(emailIdentity.verified_at)
|
||||
} : null,
|
||||
passkeys: passkeys.map((passkey) => ({
|
||||
id: String(passkey.id),
|
||||
name: String(passkey.name),
|
||||
deviceType: String(passkey.device_type),
|
||||
backedUp: Boolean(passkey.backed_up),
|
||||
lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
|
||||
createdAt: String(passkey.created_at)
|
||||
})),
|
||||
authenticationMethods,
|
||||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||||
avatarPresets: AVATAR_PRESETS,
|
||||
notifications,
|
||||
notificationUnread: Number(notificationUnread?.count ?? 0)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { agentProfileHref } from '$lib/agents';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
import {
|
||||
profileCommentFollowerRecipients,
|
||||
profileCommentRootId
|
||||
} from '$lib/server/profile-comment-thread';
|
||||
|
||||
type CommentTreeRow = {
|
||||
id: unknown;
|
||||
parent_id: unknown;
|
||||
};
|
||||
|
||||
export async function profileCommentConversation(
|
||||
commentId: string,
|
||||
profileOwnerId: string
|
||||
): Promise<{ rootId: string; rows: Array<{ id: string; parentId: string | null }> } | null> {
|
||||
const database = await getDatabase();
|
||||
const selected = await (await database.prepare(`SELECT comments.id,comments.thread_id
|
||||
FROM comments
|
||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
WHERE comments.id=? AND comments.status='visible'
|
||||
AND thread.target_type='profile' AND thread.target_id=?`))
|
||||
.get(commentId, profileOwnerId);
|
||||
if (!selected?.thread_id) return null;
|
||||
const records = await (await database.prepare(
|
||||
`SELECT id,parent_id FROM comments WHERE thread_id=? AND status IN ('visible','removed')`
|
||||
)).all(selected.thread_id) as CommentTreeRow[];
|
||||
const rows = records.map((row) => ({
|
||||
id: String(row.id),
|
||||
parentId: row.parent_id ? String(row.parent_id) : null
|
||||
}));
|
||||
const rootId = profileCommentRootId(rows, commentId);
|
||||
return rootId ? { rootId, rows } : null;
|
||||
}
|
||||
|
||||
export async function notifyProfileCommentFollowers(input: {
|
||||
commentId: string;
|
||||
authorId: string;
|
||||
authorDisplayName: string;
|
||||
profileOwnerId: string;
|
||||
profileOwnerDisplayName: string;
|
||||
profileOwnerPublicSlug: string | null;
|
||||
directRecipientId: string | null;
|
||||
}) {
|
||||
const conversation = await profileCommentConversation(input.commentId, input.profileOwnerId);
|
||||
if (!conversation) return;
|
||||
const database = await getDatabase();
|
||||
const followerRows = await (await database.prepare(
|
||||
'SELECT user_id FROM comment_thread_follows WHERE root_comment_id=?'
|
||||
)).all(conversation.rootId);
|
||||
const recipients = profileCommentFollowerRecipients(
|
||||
input.authorId,
|
||||
input.directRecipientId,
|
||||
followerRows.map((row) => String(row.user_id))
|
||||
);
|
||||
const link = agentProfileHref({
|
||||
id: input.profileOwnerId,
|
||||
publicSlug: input.profileOwnerPublicSlug
|
||||
}, `profile-comment-${input.commentId}`);
|
||||
for (const userId of recipients) {
|
||||
await createNotification({
|
||||
userId,
|
||||
category: 'comments',
|
||||
source: 'agent',
|
||||
title: 'New reply in a followed conversation',
|
||||
body: `${input.authorDisplayName} replied in a conversation you follow on ${input.profileOwnerDisplayName}'s Agent profile.`,
|
||||
link,
|
||||
actions: [{ label: 'VIEW REPLY', href: link }]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
orderProfileCommentThread,
|
||||
profileCommentFollowerRecipients,
|
||||
profileCommentRootId,
|
||||
profileCommentNotificationRecipient
|
||||
} from './profile-comment-thread';
|
||||
|
||||
const row = (
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
createdAt: string,
|
||||
status: 'visible' | 'removed' = 'visible'
|
||||
) => ({ id, parentId, createdAt, status, label: id });
|
||||
|
||||
describe('profile comment threading', () => {
|
||||
it('shows newest conversations first and replies in reading order', () => {
|
||||
const ordered = orderProfileCommentThread([
|
||||
row('older-root', null, '2026-01-01T00:00:00.000Z'),
|
||||
row('newer-root', null, '2026-01-03T00:00:00.000Z'),
|
||||
row('later-reply', 'newer-root', '2026-01-03T02:00:00.000Z'),
|
||||
row('first-reply', 'newer-root', '2026-01-03T01:00:00.000Z'),
|
||||
row('nested-reply', 'first-reply', '2026-01-03T01:30:00.000Z')
|
||||
]);
|
||||
|
||||
expect(ordered.map((comment) => [comment.id, comment.depth])).toEqual([
|
||||
['newer-root', 0],
|
||||
['first-reply', 1],
|
||||
['nested-reply', 2],
|
||||
['later-reply', 1],
|
||||
['older-root', 0]
|
||||
]);
|
||||
expect(ordered.find((comment) => comment.id === 'newer-root')?.hasReplies).toBe(true);
|
||||
expect(ordered.find((comment) => comment.id === 'nested-reply')?.rootId).toBe('newer-root');
|
||||
});
|
||||
|
||||
it('keeps removed ancestors only when they preserve a visible conversation', () => {
|
||||
const ordered = orderProfileCommentThread([
|
||||
row('removed-parent', null, '2026-01-01T00:00:00.000Z', 'removed'),
|
||||
row('visible-reply', 'removed-parent', '2026-01-02T00:00:00.000Z'),
|
||||
row('removed-leaf', null, '2026-01-03T00:00:00.000Z', 'removed')
|
||||
]);
|
||||
|
||||
expect(ordered.map((comment) => comment.id)).toEqual(['removed-parent', 'visible-reply']);
|
||||
});
|
||||
|
||||
it('recovers visible orphaned and cyclic rows as top-level conversations', () => {
|
||||
const ordered = orderProfileCommentThread([
|
||||
row('orphan', 'missing', '2026-01-03T00:00:00.000Z'),
|
||||
row('cycle-a', 'cycle-b', '2026-01-01T00:00:00.000Z'),
|
||||
row('cycle-b', 'cycle-a', '2026-01-02T00:00:00.000Z')
|
||||
]);
|
||||
|
||||
expect(new Set(ordered.map((comment) => comment.id))).toEqual(
|
||||
new Set(['orphan', 'cycle-a', 'cycle-b'])
|
||||
);
|
||||
expect(ordered.every((comment) => Number.isFinite(comment.depth))).toBe(true);
|
||||
});
|
||||
|
||||
it('notifies the direct reply recipient without sending self-notifications', () => {
|
||||
expect(profileCommentNotificationRecipient('author', 'parent-author', 'owner'))
|
||||
.toBe('parent-author');
|
||||
expect(profileCommentNotificationRecipient('author', 'author', 'owner'))
|
||||
.toBe('owner');
|
||||
expect(profileCommentNotificationRecipient('owner', null, 'owner'))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a nested reply to its top-level conversation', () => {
|
||||
const rows = [
|
||||
row('root', null, '2026-01-01T00:00:00.000Z'),
|
||||
row('reply', 'root', '2026-01-02T00:00:00.000Z'),
|
||||
row('nested', 'reply', '2026-01-03T00:00:00.000Z')
|
||||
];
|
||||
expect(profileCommentRootId(rows, 'nested')).toBe('root');
|
||||
expect(profileCommentRootId(rows, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('deduplicates followers and excludes the author and direct recipient', () => {
|
||||
expect(profileCommentFollowerRecipients(
|
||||
'author',
|
||||
'direct',
|
||||
['follower', 'direct', 'author', 'follower', 'second']
|
||||
)).toEqual(['follower', 'second']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
export type ProfileCommentThreadRow = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
status: 'visible' | 'removed';
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ThreadedProfileComment<T extends ProfileCommentThreadRow> = T & {
|
||||
depth: number;
|
||||
hasReplies: boolean;
|
||||
rootId: string;
|
||||
};
|
||||
|
||||
const timestamp = (value: string) => {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
export function orderProfileCommentThread<T extends ProfileCommentThreadRow>(
|
||||
rows: T[]
|
||||
): Array<ThreadedProfileComment<T>> {
|
||||
const nodes = new Map(rows.map((row) => [
|
||||
row.id,
|
||||
{ row, children: [] as T[] }
|
||||
]));
|
||||
const roots: T[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const parent = row.parentId && row.parentId !== row.id
|
||||
? nodes.get(row.parentId)
|
||||
: null;
|
||||
if (parent) parent.children.push(row);
|
||||
else roots.push(row);
|
||||
}
|
||||
|
||||
const renderable = new Map<string, boolean>();
|
||||
const visibilityStack = new Set<string>();
|
||||
const hasVisibleContent = (row: T): boolean => {
|
||||
const cached = renderable.get(row.id);
|
||||
if (cached !== undefined) return cached;
|
||||
if (visibilityStack.has(row.id)) return row.status === 'visible';
|
||||
visibilityStack.add(row.id);
|
||||
const result = row.status === 'visible'
|
||||
|| Boolean(nodes.get(row.id)?.children.some(hasVisibleContent));
|
||||
visibilityStack.delete(row.id);
|
||||
renderable.set(row.id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const byNewest = (left: T, right: T) =>
|
||||
timestamp(right.createdAt) - timestamp(left.createdAt) || right.id.localeCompare(left.id);
|
||||
const byOldest = (left: T, right: T) =>
|
||||
timestamp(left.createdAt) - timestamp(right.createdAt) || left.id.localeCompare(right.id);
|
||||
roots.sort(byNewest);
|
||||
for (const node of nodes.values()) node.children.sort(byOldest);
|
||||
|
||||
const ordered: Array<ThreadedProfileComment<T>> = [];
|
||||
const visited = new Set<string>();
|
||||
const append = (row: T, depth: number, rootId: string) => {
|
||||
if (visited.has(row.id) || !hasVisibleContent(row)) return;
|
||||
visited.add(row.id);
|
||||
const children = (nodes.get(row.id)?.children ?? []).filter(hasVisibleContent);
|
||||
ordered.push({ ...row, depth, hasReplies: children.length > 0, rootId });
|
||||
for (const child of children) append(child, depth + 1, rootId);
|
||||
};
|
||||
|
||||
for (const root of roots) append(root, 0, root.id);
|
||||
for (const row of [...rows].sort(byNewest)) append(row, 0, row.id);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function profileCommentRootId(
|
||||
rows: Array<Pick<ProfileCommentThreadRow, 'id' | 'parentId'>>,
|
||||
commentId: string
|
||||
): string | null {
|
||||
const parentById = new Map(rows.map((row) => [row.id, row.parentId]));
|
||||
if (!parentById.has(commentId)) return null;
|
||||
const visited = new Set<string>();
|
||||
let currentId = commentId;
|
||||
while (true) {
|
||||
if (visited.has(currentId)) return commentId;
|
||||
visited.add(currentId);
|
||||
const parentId = parentById.get(currentId);
|
||||
if (!parentId || parentId === currentId || !parentById.has(parentId)) return currentId;
|
||||
currentId = parentId;
|
||||
}
|
||||
}
|
||||
|
||||
export function profileCommentFollowerRecipients(
|
||||
authorId: string,
|
||||
directRecipientId: string | null,
|
||||
followerIds: string[]
|
||||
): string[] {
|
||||
return [...new Set(followerIds)].filter(
|
||||
(userId) => userId !== authorId && userId !== directRecipientId
|
||||
);
|
||||
}
|
||||
|
||||
export function profileCommentNotificationRecipient(
|
||||
authorId: string,
|
||||
parentAuthorId: string | null,
|
||||
profileOwnerId: string
|
||||
): string | null {
|
||||
if (parentAuthorId && parentAuthorId !== authorId) return parentAuthorId;
|
||||
return profileOwnerId !== authorId ? profileOwnerId : null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { normalizeUserMarkdown } from '$lib/markdown';
|
||||
import {
|
||||
moderateCommunityText,
|
||||
recordCommunityModerationScan
|
||||
} from '$lib/server/community-moderation';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export async function saveProfileBio(user: { id: string; displayName: string }, value: FormDataEntryValue | null) {
|
||||
const bio = normalizeUserMarkdown(value, 4_000);
|
||||
if (bio === null) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: 'Agent biographies may contain up to 4,000 characters and cannot contain control characters.'
|
||||
};
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const database = await getDatabase();
|
||||
const moderation = await moderateCommunityText({ contentType: 'profile_bio', text: bio });
|
||||
const candidateRevisionId = randomUUID();
|
||||
await (await database.prepare(`INSERT OR IGNORE INTO moderated_content_revisions
|
||||
(id,content_type,content_id,submitted_by,body_markdown,content_hash,created_at)
|
||||
VALUES(?,'profile_bio',?,?,?,?,?)`))
|
||||
.run(candidateRevisionId, user.id, user.id, bio, moderation.contentHash, now);
|
||||
const storedRevision = await (await database.prepare(`SELECT id
|
||||
FROM moderated_content_revisions
|
||||
WHERE content_type='profile_bio' AND content_id=? AND content_hash=?`))
|
||||
.get(user.id, moderation.contentHash);
|
||||
const revisionId = String(storedRevision?.id ?? candidateRevisionId);
|
||||
if (moderation.decision === 'approved') {
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,1,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
bio_markdown=excluded.bio_markdown,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`))
|
||||
.run(user.id, user.displayName, bio, now, now);
|
||||
}
|
||||
await recordCommunityModerationScan({
|
||||
contentType: 'profile_bio',
|
||||
contentId: user.id,
|
||||
revisionId,
|
||||
authorId: user.id,
|
||||
result: moderation
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
bio,
|
||||
reviewQueued: moderation.decision !== 'approved'
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProfileVisibility(
|
||||
user: { id: string; displayName: string },
|
||||
value: FormDataEntryValue | null
|
||||
) {
|
||||
const isPublicValue = String(value ?? '');
|
||||
if (!['0', '1'].includes(isPublicValue)) {
|
||||
return { ok: false as const, error: 'Choose whether your Agent profile is public.' };
|
||||
}
|
||||
const isPublic = isPublicValue === '1';
|
||||
const now = new Date().toISOString();
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,is_public,updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
is_public=excluded.is_public,
|
||||
updated_at=excluded.updated_at`))
|
||||
.run(user.id, user.displayName, isPublic ? 1 : 0, now);
|
||||
return { ok: true as const, isPublic };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
|
||||
export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
const [
|
||||
@@ -49,7 +50,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
@@ -121,10 +122,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
...brand,
|
||||
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
|
||||
})),
|
||||
weapons: weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
|
||||
})),
|
||||
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { attachWeaponCategoryBonuses, WEAPON_CATEGORIES } from './category-bonuses';
|
||||
|
||||
describe('weapon category bonuses', () => {
|
||||
it('defines every supported weapon category once', () => {
|
||||
expect(WEAPON_CATEGORIES).toEqual(['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']);
|
||||
expect(new Set(WEAPON_CATEGORIES).size).toBe(6);
|
||||
});
|
||||
|
||||
it('gives standard and exotic weapons the same category profile', () => {
|
||||
const weapons = [
|
||||
{ id: 'standard', category: 'MMR', is_exotic: 0 },
|
||||
{ id: 'exotic', category: 'MMR', is_exotic: 1 },
|
||||
{ id: 'other', category: 'AR', is_exotic: 0 }
|
||||
];
|
||||
const bonuses = [
|
||||
{ weapon_category: 'MMR', stat_attribute_id: 'crit', value: 5, sort_order: 1 },
|
||||
{ weapon_category: 'MMR', stat_attribute_id: 'headshot', value: 70, sort_order: 3 },
|
||||
{ weapon_category: 'AR', stat_attribute_id: 'headshot', value: 40, sort_order: 3 }
|
||||
];
|
||||
|
||||
const result = attachWeaponCategoryBonuses(weapons, bonuses);
|
||||
|
||||
expect(result[0].baseBonuses).toEqual(result[1].baseBonuses);
|
||||
expect(result[0].baseBonuses.map((bonus) => bonus.value)).toEqual([5, 70]);
|
||||
expect(result[2].baseBonuses.map((bonus) => bonus.value)).toEqual([40]);
|
||||
expect(weapons.some((weapon) => 'baseBonuses' in weapon)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const WEAPON_CATEGORIES = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'] as const;
|
||||
|
||||
export type WeaponCategory = (typeof WEAPON_CATEGORIES)[number];
|
||||
|
||||
export function attachWeaponCategoryBonuses<
|
||||
TWeapon extends { category: unknown },
|
||||
TBonus extends { weapon_category: unknown }
|
||||
>(weapons: TWeapon[], bonuses: TBonus[]) {
|
||||
return weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
baseBonuses: bonuses.filter((bonus) => bonus.weapon_category === weapon.category)
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
excludedTierZeroWeaponModSlot,
|
||||
expandSharedWeaponModSlots,
|
||||
normalizeWeaponModSlotPayload,
|
||||
sharedWeaponModSlots,
|
||||
validateWeaponModSlots,
|
||||
weaponModSlotsComplete,
|
||||
type WeaponModSlotConfig
|
||||
} from './mod-slots';
|
||||
|
||||
const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({ tier, slots });
|
||||
|
||||
describe('weapon mod slot configuration', () => {
|
||||
it('accepts missing and partial slot data for later configuration', () => {
|
||||
const slots = [
|
||||
configuration(0, []),
|
||||
configuration(1, ['Optics', 'Muzzle']),
|
||||
configuration(2, [])
|
||||
];
|
||||
|
||||
expect(validateWeaponModSlots(0, slots)).toBeNull();
|
||||
expect(weaponModSlotsComplete(0, slots)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks exact slot counts at every available tier as complete', () => {
|
||||
const slots = [
|
||||
configuration(1, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel']),
|
||||
configuration(2, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'])
|
||||
];
|
||||
|
||||
expect(validateWeaponModSlots(1, slots)).toBeNull();
|
||||
expect(weaponModSlotsComplete(1, slots)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unavailable slot types and counts above the tier limit', () => {
|
||||
expect(validateWeaponModSlots(2, [configuration(2, ['Unknown'])])).toContain('unavailable');
|
||||
expect(validateWeaponModSlots(0, [
|
||||
configuration(0, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'])
|
||||
])).toContain('at most 4');
|
||||
});
|
||||
|
||||
it('combines the shared T1/T2 selection with one T0 exclusion', () => {
|
||||
const shared = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'];
|
||||
const expanded = expandSharedWeaponModSlots(shared, 'Barrel');
|
||||
|
||||
expect(expanded).toEqual([
|
||||
configuration(0, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine']),
|
||||
configuration(1, shared),
|
||||
configuration(2, shared)
|
||||
]);
|
||||
expect(sharedWeaponModSlots(expanded)).toEqual(shared);
|
||||
expect(excludedTierZeroWeaponModSlot(shared, expanded)).toBe('Barrel');
|
||||
expect(weaponModSlotsComplete(0, expanded)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps T0 unconfigured when its excluded slot is unknown', () => {
|
||||
const expanded = expandSharedWeaponModSlots(
|
||||
['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'],
|
||||
''
|
||||
);
|
||||
|
||||
expect(expanded[0].slots).toEqual([]);
|
||||
expect(weaponModSlotsComplete(0, expanded)).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes duplicate tiers and ignores malformed rows', () => {
|
||||
expect(normalizeWeaponModSlotPayload([
|
||||
null,
|
||||
{ tier: 1, slots: ['Optics', 'Muzzle'] },
|
||||
{ tier: 1, slots: ['Muzzle', 'Barrel'] },
|
||||
{ tier: 7, slots: ['Trigger'] }
|
||||
])).toEqual([
|
||||
configuration(1, ['Optics', 'Muzzle', 'Barrel'])
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
export const WEAPON_MOD_SLOT_TYPES = [
|
||||
'Optics',
|
||||
'Muzzle',
|
||||
'Underbarrel',
|
||||
'Magazine',
|
||||
'Barrel',
|
||||
'Upper Receiver',
|
||||
'Lower Receiver',
|
||||
'Trigger'
|
||||
] as const;
|
||||
|
||||
export type WeaponModSlotConfig = {
|
||||
tier: number;
|
||||
slots: string[];
|
||||
};
|
||||
|
||||
export function normalizeWeaponModSlotPayload(payload: unknown): WeaponModSlotConfig[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
const byTier = new Map<number, Set<string>>();
|
||||
for (const row of payload) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const tier = Number('tier' in row ? row.tier : Number.NaN);
|
||||
if (!Number.isInteger(tier) || tier < 0 || tier > 2) continue;
|
||||
const slots = 'slots' in row && Array.isArray(row.slots) ? row.slots.map((slot: unknown) => String(slot)) : [];
|
||||
const collected = byTier.get(tier) ?? new Set<string>();
|
||||
for (const slot of slots) collected.add(slot);
|
||||
byTier.set(tier, collected);
|
||||
}
|
||||
return [...byTier.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([tier, slots]) => ({ tier, slots: [...slots] }));
|
||||
}
|
||||
|
||||
export function requiredWeaponModSlotCount(tier: number): number {
|
||||
return tier === 0 ? 4 : 5;
|
||||
}
|
||||
|
||||
export function sharedWeaponModSlots(configurations: WeaponModSlotConfig[]): string[] {
|
||||
return [...new Set(
|
||||
configurations
|
||||
.filter((row) => Number(row.tier) === 1 || Number(row.tier) === 2)
|
||||
.flatMap((row) => row.slots)
|
||||
)];
|
||||
}
|
||||
|
||||
export function excludedTierZeroWeaponModSlot(
|
||||
sharedSlots: string[],
|
||||
configurations: WeaponModSlotConfig[]
|
||||
): string {
|
||||
const tierZeroSlots = configurations.find((row) => Number(row.tier) === 0)?.slots ?? [];
|
||||
const excluded = sharedSlots.filter((slot) => !tierZeroSlots.includes(slot));
|
||||
return excluded.length === 1 && tierZeroSlots.every((slot) => sharedSlots.includes(slot)) ? excluded[0] : '';
|
||||
}
|
||||
|
||||
export function expandSharedWeaponModSlots(sharedSlots: string[], tierZeroExcludedSlot: string): WeaponModSlotConfig[] {
|
||||
const uniqueSharedSlots = [...new Set(sharedSlots)];
|
||||
return [
|
||||
{
|
||||
tier: 0,
|
||||
slots: tierZeroExcludedSlot
|
||||
? uniqueSharedSlots.filter((slot) => slot !== tierZeroExcludedSlot)
|
||||
: []
|
||||
},
|
||||
{ tier: 1, slots: [...uniqueSharedSlots] },
|
||||
{ tier: 2, slots: [...uniqueSharedSlots] }
|
||||
];
|
||||
}
|
||||
|
||||
export function weaponModSlotsComplete(nativeTier: number, configurations: WeaponModSlotConfig[]): boolean {
|
||||
for (let tier = nativeTier; tier <= 2; tier += 1) {
|
||||
const configured = configurations.find((row) => Number(row.tier) === tier);
|
||||
if (configured?.slots.length !== requiredWeaponModSlotCount(tier)) return false;
|
||||
}
|
||||
if (nativeTier <= 1) {
|
||||
const tierOne = configurations.find((row) => Number(row.tier) === 1)?.slots ?? [];
|
||||
const tierTwo = configurations.find((row) => Number(row.tier) === 2)?.slots ?? [];
|
||||
if (tierOne.some((slot) => !tierTwo.includes(slot)) || tierTwo.some((slot) => !tierOne.includes(slot))) return false;
|
||||
}
|
||||
if (nativeTier === 0) {
|
||||
const tierZero = configurations.find((row) => Number(row.tier) === 0)?.slots ?? [];
|
||||
const tierOne = configurations.find((row) => Number(row.tier) === 1)?.slots ?? [];
|
||||
if (tierZero.some((slot) => !tierOne.includes(slot))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateWeaponModSlots(nativeTier: number, configurations: WeaponModSlotConfig[]): string | null {
|
||||
for (let tier = nativeTier; tier <= 2; tier += 1) {
|
||||
const configured = configurations.find((row) => Number(row.tier) === tier);
|
||||
const slots = configured?.slots ?? [];
|
||||
const required = requiredWeaponModSlotCount(tier);
|
||||
if (slots.some((slot) => !WEAPON_MOD_SLOT_TYPES.includes(slot as (typeof WEAPON_MOD_SLOT_TYPES)[number]))) {
|
||||
return `Tier ${tier} includes an unavailable Weapon Mod slot.`;
|
||||
}
|
||||
if (slots.length > required) {
|
||||
return `Tier ${tier} supports at most ${required} unique Weapon Mod slots.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user