feat: expand profiles and weapon configuration
Validate and deploy production / validate (push) Successful in 26s
Validate and deploy staging / validate (push) Successful in 25s
Validate and deploy production / deploy (push) Failing after 1m10s
Validate and deploy staging / deploy (push) Failing after 8s

This commit is contained in:
JB
2026-07-28 00:11:40 -04:00
parent 0b3be3587b
commit a434cbe60c
37 changed files with 2209 additions and 800 deletions
+14 -5
View File
@@ -164,6 +164,14 @@ CREATE TABLE IF NOT EXISTS comments (
);
CREATE INDEX IF NOT EXISTS comments_thread_status_created_idx ON comments(thread_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS comments_author_status_created_idx ON comments(author_id, status, created_at DESC);
CREATE TABLE IF NOT EXISTS comment_thread_follows (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
root_comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY(user_id, root_comment_id)
);
CREATE INDEX IF NOT EXISTS comment_thread_follows_root_idx
ON comment_thread_follows(root_comment_id, user_id);
CREATE TABLE IF NOT EXISTS community_moderation_settings (
id TEXT PRIMARY KEY CHECK(id='primary'),
automated_enabled INTEGER NOT NULL DEFAULT 0 CHECK(automated_enabled IN (0,1)),
@@ -859,14 +867,15 @@ CREATE TABLE IF NOT EXISTS weapon_augmentation_effects (
UNIQUE(weapon_id, sort_order),
UNIQUE(weapon_id, stat_attribute_id)
);
CREATE TABLE IF NOT EXISTS weapon_base_attribute_bonuses (
CREATE TABLE IF NOT EXISTS weapon_category_attribute_bonuses (
id TEXT PRIMARY KEY,
weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE,
weapon_category TEXT NOT NULL CHECK(weapon_category IN ('Shotgun','Rifle','MMR','AR','LMG','SMG')),
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
value REAL NOT NULL CHECK(value > 0),
sort_order INTEGER NOT NULL CHECK(sort_order >= 1),
UNIQUE(weapon_id, stat_attribute_id),
UNIQUE(weapon_id, sort_order)
sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 3),
updated_at TEXT NOT NULL,
UNIQUE(weapon_category, stat_attribute_id),
UNIQUE(weapon_category, sort_order)
);
CREATE TABLE IF NOT EXISTS os_protocols (
id TEXT PRIMARY KEY,
+132 -39
View File
@@ -389,9 +389,13 @@ direct URL. Do not present authentication as a way around a disabled section.
### Agent and authentication surfaces
- Account pages should read as an agent dossier: identity and access at the top,
activity metrics beside it, archives in the primary column, and security
controls in a narrower console column.
- The canonical signed-in account surface is the owner’s public Agent profile at
`/agents/{slug}`. `/account` is a compatibility route that redirects there;
the full notification inbox remains at `/account/notifications`.
- Owners see the same dossier and conversation that visitors see, plus inline
bio editing and a compact Account Control widget in the right rail. Do not
create a separate profile-editing dashboard or remove community context while
the owner manages their profile.
- Dense account consoles use a tabbed control surface for profile, credentials,
linked identities, and account lifecycle actions. Only one panel is shown at
a time; validation failures reopen the panel containing the failed action.
@@ -446,8 +450,10 @@ direct URL. Do not present authentication as a way around a disabled section.
- ISAC user dossiers reuse the same linked-identity node. Administrative
unlinking requires a scoped identity-management capability, produces an audit
event, and must never remove the user's only available sign-in method.
- On small screens, the split frame becomes a single column and account controls
move before archival content.
- On small screens, the split frame becomes one column. Show the public dossier
and archives first, followed by Account Control, notifications, and Recent
Discussion. Place Profile Messages at the bottom. Give owners a mobile-only
header link to Account Control.
- Public display names and private login usernames are distinct. Display names
support normalized Unicode and relaxed visible characters. They are unique
under the product's normalized identity key and generate readable,
@@ -458,38 +464,89 @@ direct URL. Do not present authentication as a way around a disabled section.
### Public Agent profiles
Public Agent profiles are community dossiers, not account-control screens. Lead
with the shared `AgentIdentityLink` in its large state: circular portrait,
display name, explicit `SHD.BUILD AGENT PROFILE` context, and a directional
link cue. Place assigned site-role badges beneath the identity as a labeled
semantic list. Show role names only, in alphabetical order, and omit the list
when the Agent has no assigned roles. Badges use the shared raised surface,
explicit role text, and a small orange diamond; they wrap without truncating
long or multiple role names. Never expose role descriptions, permissions,
direct grants, or access scopes on public profiles. The profile owner may
receive an `EDIT MY PROFILE` action in the header. Keep public content totals
in a compact telemetry strip below the identity header, then place the field
bio, public builds, published guides, and recent discussion in the primary
column. Profile messages occupy the narrower communications column and may
remain sticky where the viewport has room.
Public Agent profiles are community dossiers. The owner view extends the same
page with account controls without changing its public hierarchy. Lead with the
shared `AgentIdentityLink` in its large state: circular portrait, display name,
and assigned role badges. Place role badges to the right of the display name on
desktop and below the name on mobile. Show role names only, in alphabetical
order, and omit the list when the Agent has no assigned roles. Badges use the
shared raised surface, explicit role text, and a small orange
diamond; they wrap without truncating long or multiple role names. Never expose
role descriptions, permissions, direct grants, or access scopes on public
profiles. The owner header contains the visibility switch, its information
control, and a mobile-only Account Control link. Present the primary identity
and its actions directly on the page canvas
without a card surface, border, or decorative accent rule. Give the identity and
actions content-sized responsive tracks so long names, multiple roles, and
mobile actions cannot overlap the public content totals. Keep those totals in a
compact telemetry strip below the identity header. Use four columns on desktop
and two columns on mobile. Place the field bio in the primary column and keep
its mobile Edit Bio action compact within the section header. Put Public Builds
and Published Guides side by side directly
beneath the bio; stack them on narrow screens. Their cards stay within their own
half of the primary column. Profile Messages follow the archive pair within the
same column and must not inherit height from the right rail.
Recent Discussion occupies the narrower activity rail. Owner-only Account
Control and notification-preview widgets appear above it. The profile preview
shows unread notifications only; the full inbox retains notification history.
Recent-discussion
entries use compact destination cards because each entry links to content
elsewhere on the site. Show only the discussion type, destination title, comment
date, and open action; do not repeat the message body in this rail. Reset nested
card headers to content height and align the destination identity with its date;
do not inherit the application header's fixed height or surface treatment.
Profile messages continue directly
beneath the biography in the primary column so the conversation begins without
dead space.
Use `AgentIdentityLink` for author attribution on builds, guides, comments, and
other community records. Its compact and default states combine the circular
portrait, public display name, optional contextual metadata, and a clear link
cue. Hover and focus-visible states may turn the name and cue orange. The
portrait remains circular at every size; selectors may use square tactical
thumbnails only where the avatar rules explicitly allow them.
portrait, public display name, assigned public role badges, and optional
contextual metadata. Role badges remain beside the display name in comment
headers and other author attributions; an empty role list renders no badge. The
display name supplies the link affordance through its hover and focus-visible
states; do not add a trailing arrow. Use the primary orange token for the
portrait border, with a two-pixel edge that remains legible against the page
canvas. The default author portrait is 44 pixels; compact identities may use 30
pixels. The portrait remains circular at every size; selectors may use square
tactical thumbnails only where the avatar rules explicitly allow them.
Public-profile links use the stored readable slug, URL-encoded as one path
segment. A display-name change updates the canonical slug. Legacy UUID routes
remain valid and redirect to the canonical address; UUIDs remain the internal
identity for mutations, relationships, and audit evidence.
Public biographies and community comments use the shared `MarkdownEditor` and
`MarkdownContent` pair. The editor supplies a small formatting toolbar,
remaining-character count, concise help, a readable editorial typeface, and a
minimum 44px touch target for toolbar actions on narrow screens. It supports
headings, lists, quotes, links, emphasis, and inline code. Raw HTML is escaped.
Public biographies use the shared `MarkdownEditor` and `MarkdownContent` pair.
The editor supplies formatting controls, a remaining-character count, concise
help, and a readable editorial typeface. Profile messages and replies use the
compact `CommentComposer`: one textarea, a Markdown notice, a character count,
and submit or cancel actions. Rendered Markdown supports headings, lists,
quotes, links, bold, italics, and inline code. Load the required editorial font
weights and italic face because the global type system disables synthetic font
styles. Raw HTML is escaped.
Profile conversations begin with a collapsed `LEAVE A MESSAGE` action. A reply
opens the same editor beneath the selected message, and only one editor remains
open. Comments sit on the page canvas without cards, section surfaces, or
detached author headers. Align each body and action row with the username rather
than the avatar gutter. Keep Reply visible below the message. Place Follow or
Unfollow, Report, and the author-only Remove action in a shared ellipsis menu
beside Reply. The menu uses a 44-pixel trigger, returns focus when
closed, supports Escape and directional-key navigation, and becomes a fixed
bottom panel on narrow screens. Keep destructive actions neutral at rest and use
the danger color on hover or keyboard focus. Render conversations
in depth-first reading order: newest root conversations first and each branch
oldest-first. Use one-pixel guide lines and restrained indentation for replies,
then cap visual indentation on narrow screens while retaining the logical
parent chain. If an author or moderator removes a message that has replies, show
a bodyless `MESSAGE REMOVED` placeholder so readers can follow the remaining
conversation. Following applies to the top-level conversation and sends inbox
notifications for visible descendant replies, excluding the reply author and
any recipient already receiving the direct-reply notification. Send reply
notifications to the direct parent author and link to the specific reply.
Recent-discussion links target the exact comment anchor instead of the
containing profile or content page.
Public user-generated content uses the shared `ContentReport` control when the
viewer is authenticated and does not own the content. Keep the control visually
@@ -538,8 +595,10 @@ record is absent without implying an error.
Profile visibility is an explicit public or hidden account setting. A hidden
profile is excluded from public discovery and cannot receive new profile
messages. Hiding it does not remove the Agent's display name or portrait from
public builds, guides, or comments they authored. State this consequence beside
the visibility control so the setting does not imply retroactive anonymity.
public builds, guides, or comments they authored. Put the compact textual
`PUBLIC` or `HIDDEN` switch in the owner’s identity header and explain the
consequence in an adjacent information popover. Display-name restrictions use
the same popover pattern instead of persistent instructional copy.
Community forms use `PendingForm` to block duplicate submissions. Pending
actions retain their label context, validation errors preserve entered text,
@@ -808,8 +867,10 @@ Repository agents and contributors should follow the maintenance checklist in
### Agent avatars
Agent portraits use a circular crop in identity headers and square tactical
thumbnails in selectors. Preset choices appear in a compact four-column grid;
the active choice uses the orange action border and an explicit pressed state.
thumbnails in selectors. Account Control shows the current portrait first and
keeps preset and upload controls behind an explicit Edit Avatar action. Preset
choices appear in a compact four-column grid; the active choice uses the orange
action border and an explicit pressed state.
Custom images are normalized server-side to a metadata-free 256 × 256 WebP.
Selectors must retain visible hover, focus-visible, pressed, pending, and
disabled states, and collapse naturally with their containing account console.
@@ -826,13 +887,12 @@ navigation links use the same height, typography, and interaction motion.
Complex progression definitions use a stacked control-surface editor: identity
and fixed values first, then parallel tier cards, mutually exclusive talent
pools, fixed base bonuses, and the augmentation sequence. Tier cards retain
pools, and the augmentation sequence. Tier cards retain
identical row ordering so values can be compared horizontally on wide screens
and stack without changing meaning on narrow screens. System-derived augment
steps appear as read-only numbered cells; editable effects use the shared
attribute/value row pattern. Exotic is a status layered onto a weapon family,
never a replacement for the family classification. Fixed base bonuses remain
visually separate from tactical augmentation effects. Exotic definitions add a
never a replacement for the family classification. Exotic definitions add a
dedicated exotic-only talent after the two standard talent pools; their sixth
augmentation cell communicates that this talent advances to Tier II.
The exotic talent is owned by exactly one exotic weapon and is authored inline
@@ -842,6 +902,14 @@ Tier I/II previews, and the shared source-owned mechanic program editor. The Tal
Registry may display an owned exotic talent for discovery, but routes editing
back to its weapon rather than opening the shared-talent editor.
Place shared weapon-category bonuses above the weapon registry in one collapsed
profile console. Its six category buttons show the three inherited Attribute
values and open one category editor. The editor keeps Attribute identities
fixed and exposes only their positive numeric values. Saving closes the editor;
validation preserves its values and error state. Standard and Exotic weapons
inherit the profile for their underlying Shotgun, Rifle, MMR, AR, LMG, or SMG
category. Do not repeat these controls inside individual weapon editors.
Large relationship sets use the searchable assignment control instead of
checkbox walls. It keeps assigned records visible as removable chips, filters
available records by identity, renders at most thirty results at once, and
@@ -1426,6 +1494,25 @@ large enough that scanning a dropdown is inefficient. The selected record,
pending-contribution state, keyboard focus, and remove interaction must remain
explicit in both modes.
Records with established visual identity may supply an optional square icon.
Standard assignment lists show it at 32 pixels in result rows and 24 pixels in
selected chips. An icon-first multiselect may use the `visual-grid` presentation
for a finite visual registry. Its compact tiles use a 38-pixel icon without
persistent tile text. Hover and keyboard focus reveal the record name, while
the accessible label includes the name and assignment state. The orange inset
edge marks selected tiles. Weapon Talent 1 and Talent 2 pools and the Backpack
and Body Armor talent pools use this grid. Use the shared missing-item artwork
when a record lacks an icon. Render the full eligible set without a search,
result-count row, item cap, or internal scroll region; the section header owns
the selected count. Reflow tiles without changing keyboard order.
Use the `toggle-grid` presentation for a small fixed registry whose text labels
are the primary recognition aid. Each 44-pixel-or-taller option uses checkbox
semantics and shows only the record name. The orange action edge marks the
selected state, and the accessible label includes that state. Weapon mod slot
selectors use this presentation so an operator can toggle each slot by clicking
its name. Keep all options visible and preserve DOM order as the grid reflows.
Every ISAC relationship to an Attribute uses `SearchableAssignment`; native
attribute dropdowns are not permitted because the registry is expected to
contain hundreds of records. Use full mode for primary editor sections and
@@ -1679,10 +1766,16 @@ persistence controls and shared-loadout columns stack vertically.
When compatibility depends on two independent dimensions, keep those
dimensions explicit rather than presenting a flattened list. Weapon
definitions configure their available Weapon Mod slots per tier. T0 requires
four distinct slots; T1 and T2 require five. Weapon Mod definitions then
configure one slot plus one or more compatible weapon categories. The UI and
validation must require both dimensions to match.
definitions configure one shared set of five Weapon Mod slots for T1 and T2.
T0 uses the same set minus one explicitly selected slot. A T2-native weapon
shows only its applicable shared-slot control, and tiers below the native tier
remain unavailable. Operators may save an unverified shared set or T0 exclusion
when they cannot inspect the weapon in-game. Mark an active weapon `CONFIG
REQUIRED` until every available tier has its full slot count, and keep verified
assignments editable. Reject unknown slot types and counts above the tier limit.
Weapon Mod definitions then configure one slot plus one or more compatible
weapon categories. The UI and validation must require both dimensions to match
once the weapon definition is complete.
Weapon Mod definitions use the shared segmented-choice control for the bounded
eight-slot vocabulary, with a full-width row on desktop and two columns on
+26 -3
View File
@@ -255,6 +255,26 @@ active Administrator. A later critical transition may alert again; repeated
scheduler runs within the same episode do not. Provider errors preserve the
last known balance and never expose the API key or raw response body.
# Weapon category bonuses
`weapon_category_attribute_bonuses` stores three ordered fixed Attribute
contributions for each Shotgun, Rifle, MMR, AR, LMG, and SMG category. Each
profile grants 5% Weapon Critical Hit Chance and 50% Weapon Critical Hit Damage.
Shotgun, Rifle, AR, LMG, and SMG grant 40% Headshot Damage; MMR grants 70%.
Weapon records do not own copies of these rows. Both builder catalog loaders
attach the matching category profile when they hydrate a weapon. Exotic weapons
retain an underlying category and inherit the same profile as a standard weapon
of that type. The combat summary preserves the existing equipped-weapon
semantics: each equipped weapon contributes its inherited profile to the
Agent's accumulated Attributes.
ISAC exposes one category-profile editor above the weapon registry. Operators
may change the three positive values, while the Attribute identities and order
remain fixed. Each successful save appends a
`game_data/weapon_bonus_profile.updated` audit event with the complete previous
and resulting profile.
# Weapon-owned exotic talents
Standard weapon talent pools remain reusable across weapons and use Talent 1 /
@@ -291,9 +311,12 @@ the existing `game_data` audit history.
### Weapon Mods and tiered weapon slots
`weapon_mod_slots` records each weapon's available slot vocabulary per tier.
Canonical validation requires four unique slots for T0 and five for T1/T2 for
every tier at or above the weapon's native tier. Regular Weapon Mods declare a
single slot type and one or more compatible base weapon categories through
The editor captures one shared set for T1/T2 and the single shared slot omitted
at T0, then expands that input into the normalized per-tier rows. Operators may
save partial rows when the in-game configuration is unavailable. ISAC derives
`CONFIG REQUIRED` until every available tier contains four unique slots at T0
or five at T1/T2. Regular Weapon Mods declare a single slot type and one or more
compatible base weapon categories through
`weapon_mod_compatible_categories`; eligibility requires both a category match
and the slot to exist on the equipped weapon at its selected tier.
+48
View File
@@ -2,6 +2,7 @@ import { connect } from '@tursodatabase/database';
import { access, mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { randomUUID } from 'node:crypto';
import { initializeDefaultUsers } from './default-users.mjs';
const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
@@ -386,6 +387,53 @@ for (const statement of statements) {
await database.exec(statement);
}
const weaponBonusAttributes = await (
await database.prepare(`SELECT id,name FROM stat_attributes
WHERE name IN ('Weapon Critical Hit Chance','Weapon Critical Hit Damage','Headshot Damage')`)
).all();
if (weaponBonusAttributes.length && weaponBonusAttributes.length !== 3) {
throw new Error('The shared weapon-category bonus migration requires all three fixed weapon Attributes.');
}
if (weaponBonusAttributes.length === 3) {
const attributeId = (name) => String(weaponBonusAttributes.find((attribute) => attribute.name === name)?.id ?? '');
const defaults = [
['Weapon Critical Hit Chance', 5],
['Weapon Critical Hit Damage', 50],
['Headshot Damage', 40]
];
await database.exec('BEGIN');
try {
for (const category of ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']) {
for (let index = 0; index < defaults.length; index += 1) {
const [name, defaultValue] = defaults[index];
const value = category === 'MMR' && name === 'Headshot Damage' ? 70 : defaultValue;
await (await database.prepare(`INSERT OR IGNORE INTO weapon_category_attribute_bonuses(
id,weapon_category,stat_attribute_id,value,sort_order,updated_at
) VALUES(?,?,?,?,?,?)`)).run(
randomUUID(), category, attributeId(name), value, index + 1, new Date().toISOString()
);
}
}
const configured = await (await database.prepare(
'SELECT COUNT(*) AS count FROM weapon_category_attribute_bonuses'
)).get();
if (Number(configured?.count) !== 18) {
throw new Error('Every weapon category must have exactly three shared base Attribute bonuses.');
}
const legacyWeaponBonuses = await (await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapon_base_attribute_bonuses'"
)).get();
if (legacyWeaponBonuses) {
console.log('Replacing per-weapon base Attribute bonuses with shared weapon-category profiles.');
await database.exec('DROP TABLE weapon_base_attribute_bonuses');
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
}
const deletedRoleTable = await (await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'"
)).get();
+1 -1
View File
@@ -22,7 +22,7 @@ export const ISAC_DATA_TABLES = [
'weapon_tier_ranges',
'weapon_talent_options',
'weapon_augmentation_effects',
'weapon_base_attribute_bonuses',
'weapon_category_attribute_bonuses',
'os_protocols',
'os_protocol_primary_effects',
'os_protocol_secondary_profiles',
+32
View File
@@ -2409,3 +2409,35 @@
.modeling-content{padding:10px}
@media(max-width:560px){.modeling-disclosure>summary{min-height:56px}.modeling-content{padding:7px}}
@media(prefers-reduced-motion:reduce){.modeling-disclosure>summary{transition:none}}
/* Optional visual identity in shared assignment options. */
.assignment-item-icon{flex:0 0 auto;object-fit:contain;background:var(--shd-bg)}
.assignment-chip-icon{width:24px;height:24px}
.assignment-results>button.with-icon{min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:9px}
.assignment-result-icon{width:32px;height:32px}
.assignment-results>button.with-icon>span{min-width:0;overflow-wrap:anywhere}
.assignment-picker.visual-grid .assignment-results{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));grid-auto-rows:52px;gap:2px;max-height:none;padding:2px;overflow:visible;background:var(--shd-border-soft)}
.assignment-picker.visual-grid .assignment-results>button.visual-grid-option{position:relative;min-width:0;min-height:44px;display:grid;place-items:center;padding:5px;border:0;background:var(--shd-surface)}
.assignment-grid-icon{width:38px;height:38px}
.assignment-picker.visual-grid .assignment-results>button.visual-grid-option::after{content:attr(data-tooltip);position:absolute;left:50%;bottom:calc(100% + 6px);z-index:30;width:max-content;max-width:210px;padding:5px 7px;border:1px solid var(--shd-border-strong);background:var(--shd-bg);color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:700;line-height:1.25;white-space:normal;opacity:0;pointer-events:none;transform:translateX(-50%)}
.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:hover::after,.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:focus-visible::after{opacity:1}
.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:hover{background:var(--shd-surface-active);box-shadow:inset 0 0 0 1px var(--shd-border-strong)}
.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:-2px}
.assignment-picker.visual-grid .assignment-results>button.visual-grid-option.selected{background:var(--shd-surface-active);box-shadow:inset 0 0 0 2px var(--shd-orange)}
.assignment-picker.toggle-grid{border:0;background:transparent}
.assignment-picker.toggle-grid .assignment-results{display:grid;grid-template-columns:repeat(auto-fit,minmax(94px,1fr));gap:5px;max-height:none;overflow:visible}
.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option{min-width:0;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted)}
.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option>span{min-width:0;font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere}
.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option:hover{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-text)}
.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option.selected{border-color:var(--shd-orange);background:var(--shd-surface-active);color:var(--shd-orange);box-shadow:inset 2px 0 var(--shd-orange)}
@media(max-width:560px){.assignment-picker.visual-grid .assignment-results{grid-template-columns:repeat(auto-fill,minmax(48px,1fr))}.assignment-picker.toggle-grid .assignment-results{grid-template-columns:repeat(2,minmax(0,1fr))}}
/* Shared weapon-category bonus profiles. */
.weapon-bonus-registry{margin-top:10px}
.weapon-bonus-profile-grid>button header strong{display:flex;align-items:center;gap:6px}
.weapon-bonus-editor{border-top-color:var(--shd-success)}
.weapon-bonus-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-top:9px}
.weapon-mod-slot-note{display:flex;align-items:center;gap:10px;margin:8px 0;padding:8px 10px;border:1px solid #74552a;background:#18150d;color:#a89568;font-size:var(--shd-type-telemetry);line-height:1.45}.weapon-mod-slot-note b{flex:0 0 auto;color:var(--shd-orange);letter-spacing:.8px}.weapon-mod-slot-config{margin-top:8px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-slot-config b.config-required,.state-dot.config-required{color:var(--shd-orange)}.weapon-t0-exclusion{margin-top:10px;padding-top:9px;border-top:1px solid var(--shd-border)}.weapon-t0-exclusion>.segmented-choice{margin-top:7px}.weapon-t0-exclusion>.segmented-choice>div{height:auto;min-height:44px}@media(max-width:700px){.weapon-mod-slot-note{align-items:flex-start;flex-direction:column;gap:4px}.weapon-t0-exclusion>.segmented-choice>div{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
.weapon-bonus-fields .field{margin:0}
@media(max-width:700px){.weapon-bonus-fields{grid-template-columns:1fr}}
+188
View File
@@ -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 &amp; 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 &amp; 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>
+24 -10
View File
@@ -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>
+51
View File
@@ -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>
+17 -5
View File
@@ -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,10 +31,16 @@
String(result?.userReportContentId ?? '') === contentId
&& String(result?.userReportContentType ?? '') === contentType
);
let reportOpen = $state(false);
function closeReport() {
reportOpen = false;
}
</script>
<details class="content-report">
<summary>{label}</summary>
<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>
@@ -49,15 +59,17 @@
{#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>
+48
View File
@@ -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>
+89
View File
@@ -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>
+27 -5
View File
@@ -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,15 +66,19 @@
};
</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}
{#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)}`}><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>
<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}
@@ -70,10 +86,16 @@
</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)}>
<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>
+67
View File
@@ -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)
};
}
+72
View File
@@ -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']);
});
});
+106
View File
@@ -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;
}
+73
View File
@@ -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 };
}
+3 -5
View File
@@ -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);
+29
View File
@@ -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);
});
});
+13
View File
@@ -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)
}));
}
+77
View File
@@ -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'])
]);
});
});
+100
View File
@@ -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;
}
+2
View File
@@ -5,7 +5,9 @@
import '@fontsource/barlow/800.css';
import '@fontsource/barlow/900.css';
import '@fontsource/source-serif-4/400.css';
import '@fontsource/source-serif-4/400-italic.css';
import '@fontsource/source-serif-4/600.css';
import '@fontsource/source-serif-4/700.css';
import '../app.css';
import AppShell from '$lib/components/AppShell.svelte';
import ToastViewport from '$lib/components/ToastViewport.svelte';
+28 -146
View File
@@ -9,13 +9,12 @@ import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import { recordIsacAudit } from '$lib/server/audit';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { randomUUID } from 'node:crypto';
import {
moderateCommunityImage,
moderateCommunityText,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
import { saveProfileBio, updateProfileVisibility } from '$lib/server/profile-settings';
import { agentProfileHref, displayNameKey } from '$lib/agents';
import {
createPublicProfileSlug,
@@ -26,103 +25,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile, pendingBioRevision] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id),
(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(user.id),
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
(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(user.id),
getAuthenticationMethods(database, user.id),
(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.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
ORDER BY n.created_at DESC LIMIT 5`)).all(user.id),
(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(user.id),
(await database.prepare(`SELECT profile.bio_markdown,profile.is_public,profile.bio_updated_at,profile.public_slug,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=?`)).get(user.id),
(await database.prepare(`SELECT revision.body_markdown,revision.created_at,scan.status,scan.reason
FROM moderated_content_revisions revision
JOIN content_moderation_scans scan ON scan.revision_id=revision.id
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
WHERE revision.content_type='profile_bio' AND revision.content_id=?
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL
ORDER BY revision.created_at DESC LIMIT 1`)).get(user.id)
]);
const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = {
discord_in_use: {
title: 'DISCORD ACCOUNT ALREADY LINKED',
message: 'That Discord account belongs to another SHD.build agent. No accounts were changed. Sign out, then use Continue with Discord to access the existing account.'
},
link_session_changed: {
title: 'SESSION CHANGED',
message: 'Your SHD.build session changed while Discord was connecting. Sign in to the intended account and try linking again.'
},
discord_denied: {
title: 'DISCORD AUTHORIZATION CANCELLED',
message: 'Discord did not authorize the connection. Your SHD.build account was not changed.'
}
};
const oauthError = oauthErrorCode
? oauthErrors[oauthErrorCode] ?? {
title: 'DISCORD LINK FAILED',
message: 'Discord linking could not be completed. Your existing sign-in methods and account data were not changed.'
}
: null;
return {
user: {
...user,
bioMarkdown: String(pendingBioRevision?.body_markdown ?? profile?.bio_markdown ?? ''),
profileIsPublic: profile?.is_public === undefined ? true : Boolean(profile.is_public)
},
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
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,
hasPassword: typeof connection?.password_hash === 'string',
hasLocalCredentials: typeof connection?.password_hash === 'string' && typeof connection?.login_username === 'string',
emailIdentity: emailIdentity ? { email: String(emailIdentity.email), verifiedAt: String(emailIdentity.verified_at) } : null,
authenticationMethods,
emailDeliveryConfigured: emailDeliveryConfigured(),
emailVerified: url.searchParams.has('email_verified'),
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)
})),
chooseDisplayName: url.searchParams.has('choose_display_name'),
discordLinked: url.searchParams.has('discord_linked'),
oauthError,
avatarPresets: AVATAR_PRESETS,
profile: {
bioMarkdown: String(profile?.bio_markdown ?? ''),
bioHtml: renderUserMarkdown(profile?.bio_markdown),
isPublic: Boolean(profile?.is_public),
bioHidden: Boolean(profile?.bio_hidden),
moderationReason: profile?.moderation_reason ? String(profile.moderation_reason) : null,
bioReviewPending: Boolean(pendingBioRevision),
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
href: agentProfileHref({
const ownerProfile = await (await database.prepare(
'SELECT public_slug FROM user_profiles WHERE user_id=?'
)).get(user.id);
const profileTarget = new URL(
agentProfileHref({
id: user.id,
publicSlug: typeof profile?.public_slug === 'string' ? profile.public_slug : null
})
publicSlug: ownerProfile?.public_slug ? String(ownerProfile.public_slug) : null
}),
url.origin
);
const requestedPanel = url.searchParams.get('panel');
if (requestedPanel) profileTarget.searchParams.set('control', requestedPanel);
for (const key of ['oauth_error', 'discord_linked', 'email_verified']) {
const value = url.searchParams.get(key);
if (value) profileTarget.searchParams.set(key, value);
}
};
redirect(303, `${profileTarget.pathname}${profileTarget.search}`);
};
export const actions: Actions = {
@@ -130,73 +49,36 @@ export const actions: Actions = {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const bio = normalizeUserMarkdown(form.get('bio'), 4_000);
if (bio === null) {
const result = await saveProfileBio(user, form.get('bio'));
if (!result.ok) {
return fail(400, {
bioError: 'Agent biographies may contain up to 4,000 characters and cannot contain control characters.',
bioError: result.error,
bioResultToken: randomUUID()
});
}
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 {
bioSaved: true,
bioReviewQueued: moderation.decision !== 'approved',
bioMarkdown: bio,
bioReviewQueued: result.reviewQueued,
bioMarkdown: result.bio,
bioResultToken: randomUUID()
};
},
setProfileVisibility: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const isPublicValue = String((await request.formData()).get('isPublic') ?? '');
if (!['0', '1'].includes(isPublicValue)) {
const result = await updateProfileVisibility(
user,
(await request.formData()).get('isPublic')
);
if (!result.ok) {
return fail(400, {
profileVisibilityError: 'Choose whether your Agent profile is public.',
profileVisibilityError: result.error,
profileVisibilityResultToken: randomUUID()
});
}
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 {
profileVisibilitySaved: true,
profileIsPublic: isPublic,
profileIsPublic: result.isPublic,
profileVisibilityResultToken: randomUUID()
};
},
-304
View File
@@ -1,304 +0,0 @@
<script lang="ts">
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import { toast } from '$lib/toast';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let { data, form } = $props();
const user = $derived(data.user!);
let discordLinkPending = $state(false);
let discordUnlinkConfirm = $state(false);
type AccountPanel = 'identity' | 'access' | 'account';
type AccessPanel = 'passkeys' | 'email' | 'local' | 'connections';
type AvatarPanel = 'presets' | 'upload';
function initialAccountPanel(): AccountPanel {
if (form?.discordUnlinkError || data.oauthError || data.discordLinked || form?.discordUnlinked || form?.passwordError || form?.loginUsernameError || form?.localCredentialError || form?.emailError || form?.passkeyError || data.emailVerified) return 'access';
if (form?.deleteError) return 'account';
return 'identity';
}
function initialAccessPanel(): AccessPanel {
if (form?.discordUnlinkError || data.oauthError || data.discordLinked || form?.discordUnlinked) return 'connections';
if (form?.emailError || form?.emailVerificationSent || form?.emailRemoved || data.emailVerified) return 'email';
if (form?.passwordError || form?.loginUsernameError || form?.localCredentialError || form?.localCredentialsAdded || form?.localCredentialsRemoved) return 'local';
return 'passkeys';
}
function initialAvatarPanel(): AvatarPanel {
return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets';
}
const notificationIsIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
const notificationTone = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
return 'neutral';
};
const notificationSeverity = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(error|failed|could not)/.test(text)) return 'ERROR';
if (/(warning|violation|needs revision)/.test(text)) return 'WARNING';
return null;
};
let accountPanel = $state<AccountPanel>(initialAccountPanel());
let accessPanel = $state<AccessPanel>(initialAccessPanel());
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
let passkeySupported = $state(true);
let passkeyPending = $state(false);
let passkeyMessage = $state('');
let passkeyClientError = $state('');
let passkeyName = $state('My passkey');
let bioDraft = $state('');
let bioInitialized = $state(false);
let handledBioToken = $state('');
$effect(() => {
const token = String(form?.bioResultToken ?? '');
if (!bioInitialized) {
bioDraft = String(user.bioMarkdown ?? '');
bioInitialized = true;
}
if (!token || token === handledBioToken) return;
handledBioToken = token;
if (form?.bioSaved) {
bioDraft = String(form.bioMarkdown ?? user.bioMarkdown ?? bioDraft);
toast.success(
form?.bioReviewQueued ? 'Profile bio received' : 'Profile bio saved',
form?.bioReviewQueued
? 'Automated checks sent this revision to Oversight. Your last approved bio remains public.'
: 'Your public Agent profile has been updated.'
);
} else if (form?.bioError) {
toast.error('Profile bio not saved', String(form.bioError));
}
});
onMount(() => passkeySupported = browserSupportsWebAuthn());
async function registerPasskey() {
if (passkeyPending) return;
passkeyPending = true;
passkeyMessage = '';
passkeyClientError = '';
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.');
passkeyMessage = 'Passkey registered. Reloading account methods…';
toast.success('Passkey registered', 'Your new sign-in method is ready.');
location.reload();
} catch (error) {
passkeyClientError = error instanceof Error ? error.message : 'Passkey registration was cancelled.';
toast.error('Passkey not registered', passkeyClientError);
passkeyPending = false;
}
}
function beginDiscordLink(event: MouseEvent) {
if (discordLinkPending) { event.preventDefault(); return; }
discordLinkPending = true;
}
</script>
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><div class="profile-public-actions"><span class="role-badge">{user.role.toUpperCase()} ACCESS</span><a href={data.profile.href}>VIEW PUBLIC PROFILE →</a></div></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
{#if form?.discordUnlinked}<p class="success">Discord account unlinked. Your other configured sign-in methods remain active.</p>{/if}
{#if data.oauthError}<div class="account-alert error-alert" role="alert"><b>{data.oauthError.title}</b><span>{data.oauthError.message}</span></div>{/if}
<div class="profile-dashboard">
<section class="profile-feed">
<div class="profile-module profile-notifications">
<div class="module-head"><div><p class="eyebrow">SHD COMMUNICATION NETWORK</p><h2>NOTIFICATIONS</h2></div><a class="secondary" href="/account/notifications">OPEN INBOX{data.account.notificationUnread ? ` · ${data.account.notificationUnread} UNREAD` : ''} →</a></div>
<div class="profile-notification-list">
{#each data.account.notifications as notification}
<a class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={notificationIsIsac(notification)} class:positive={notificationTone(notification) === 'positive'} class:negative={notificationTone(notification) === 'negative'} href="/account/notifications"><div><img src={notificationIsIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp'} alt=""/><span><b>{notification.source}</b><small>{String(notification.category).toUpperCase()} · {new Date(String(notification.created_at)).toLocaleDateString()}</small></span></div><span><strong>{notification.title}</strong><small>{notification.body}</small></span><em>{notificationSeverity(notification) ?? 'OPEN'} ›</em></a>
{:else}<div class="profile-empty compact"><span>◇</span><b>NO NOTIFICATIONS</b><p>ISAC and community messages will appear here.</p></div>{/each}
</div>
</div>
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
{#if data.account.builds.length}<div class="profile-table"><div class="profile-row heading"><span>LOADOUT</span><span>VISIBILITY</span><span>UPDATED</span><span></span></div>{#each data.account.builds as build}<div class="profile-row"><div><strong>{build.title}</strong><small>/{build.slug}</small></div><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><a href={`/builder?build=${build.slug}`}>EDIT →</a></div>{/each}</div>{:else}<div class="profile-empty"><span>◇</span><b>NO BUILDS RECORDED</b><p>Your saved and published loadouts will appear here.</p><a href="/builder">CREATE YOUR FIRST BUILD →</a></div>{/if}
</div>
<div class="profile-columns"><div class="profile-module"><p class="eyebrow">SAVED INTELLIGENCE</p><h2>FAVORITES</h2><strong class="large-count">{data.account.favorites.length}</strong><span class="count-label">SAVED LOADOUTS</span></div><div class="profile-module"><p class="eyebrow">FIELD FEEDBACK</p><h2>RATINGS</h2><strong class="large-count">{data.account.ratings.length}</strong><span class="count-label">RATED LOADOUTS</span></div></div>
</section>
<aside class="account-console">
<div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div>
<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="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">
<div id="avatar" class="profile-anchor" aria-hidden="true"></div><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">
<p class="eyebrow">CUSTOM AVATAR</p>
<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/>
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
{#if form?.avatarChanged}<p class="success">{form?.avatarReviewQueued ? 'Avatar received and held for Oversight review. The default portrait remains public.' : 'Avatar updated.'}</p>{/if}
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
</PendingForm>
{/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="40" required /></label><small class="identity-note">Unique across SHD.build and used to create your readable public-profile address. 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">FIELD DOSSIER</p><h2>PUBLIC BIO</h2>
{#if data.profile.bioHidden}
<div class="account-alert error-alert" role="status"><b>BIO HIDDEN BY OVERSIGHT</b><span>{data.profile.moderationReason ?? 'Review the ISAC notification in your inbox for details.'} You may edit your bio, but it will remain hidden until Oversight restores it.</span></div>
{/if}
{#if data.profile.bioReviewPending}
<div class="account-alert" role="status"><b>BIO REVISION AWAITING REVIEW</b><span>{data.profile.bioReviewReason ?? 'Automated safety checks requested human review.'} Your last approved biography remains public while Oversight reviews this revision.</span></div>
{/if}
<PendingForm action="?/saveBio">
<MarkdownEditor
name="bio"
label="AGENT BIO"
help="Describe your playstyle, preferred roles, community work, or current objectives. Links are allowed."
maxLength={4000}
rows={10}
bind:value={bioDraft}
/>
<ModerationDisclosure/>
{#if form?.bioError}<p class="error">{form.bioError}</p>{/if}
{#if form?.bioSaved}<p class="success">{form?.bioReviewQueued ? 'Bio revision queued for Oversight review.' : 'Public bio updated.'}</p>{/if}
<button class="primary console-action">SAVE PUBLIC BIO →</button>
</PendingForm>
<div class="profile-visibility-control">
<div>
<span>PROFILE VISIBILITY</span>
<strong>{user.profileIsPublic !== false ? 'PUBLIC' : 'HIDDEN'}</strong>
<p>
{user.profileIsPublic !== false
? 'Other agents can discover this profile and leave profile messages.'
: 'This profile is excluded from discovery and cannot receive new profile messages.'}
</p>
<small>Hiding your profile does not remove your name or avatar from public builds, guides, or comments you authored.</small>
</div>
<PendingForm action="?/setProfileVisibility">
<input type="hidden" name="isPublic" value={user.profileIsPublic === false ? '1' : '0'}/>
<button class={user.profileIsPublic === false ? 'primary' : 'secondary'}>
{user.profileIsPublic === false ? 'MAKE PROFILE PUBLIC →' : 'HIDE PUBLIC PROFILE'}
</button>
</PendingForm>
</div>
{#if form?.profileVisibilityError}<p class="error">{form.profileVisibilityError}</p>{/if}
{#if form?.profileVisibilitySaved}<p class="success">Profile visibility updated.</p>{/if}
</div>
{:else if accountPanel === 'access'}
<div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab">
<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>{data.passkeys.length}</b></button>
<button class:active={accessPanel === 'email'} role="tab" aria-selected={accessPanel === 'email'} onclick={() => accessPanel = 'email'}><span>EMAIL</span><b>{data.emailIdentity ? 1 : 0}</b></button>
<button class:active={accessPanel === 'local'} role="tab" aria-selected={accessPanel === 'local'} onclick={() => accessPanel = 'local'}><span>PASSWORD</span><b>{data.hasLocalCredentials ? 1 : 0}</b></button>
<button class:active={accessPanel === 'connections'} role="tab" aria-selected={accessPanel === 'connections'} onclick={() => accessPanel = 'connections'}><span>LINKS</span><b>{data.discordConnection ? 1 : 0}</b></button>
</div>
{#if accessPanel === 'passkeys'}
<p class="eyebrow">PREFERRED ACCESS</p><h2>PASSKEYS</h2>
<p class="identity-note passkey-intro">Use Face ID, Touch ID, Windows Hello, a security key, or your password manager without entering a SHD.build password.</p>
{#if data.passkeys.length}
<div class="passkey-list">
{#each data.passkeys as passkey}
<div class="passkey-card"><div><b>{passkey.name}</b><small>{passkey.backedUp ? 'SYNCED PASSKEY' : 'DEVICE PASSKEY'} · ADDED {new Date(passkey.createdAt).toLocaleDateString()}</small></div><PendingForm action="?/removePasskey"><input type="hidden" name="passkeyId" value={passkey.id}/><button class="passkey-remove" aria-label={`Remove ${passkey.name}`} disabled={data.authenticationMethods.total <= 1}>REMOVE</button></PendingForm></div>
{/each}
</div>
{:else}<p class="account-method-empty">NO PASSKEYS REGISTERED</p>{/if}
{#if form?.passkeyError}<p class="error">{form.passkeyError}</p>{/if}
{#if form?.passkeyRemoved}<p class="success">Passkey removed.</p>{/if}
{#if passkeyClientError}<p class="error">{passkeyClientError}</p>{/if}
{#if passkeyMessage}<p class="success">{passkeyMessage}</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>
{#if !passkeySupported}<p class="connection-warning">This browser does not support passkeys.</p>{/if}
{:else if accessPanel === 'email'}
<p class="eyebrow">PASSWORDLESS RECOVERY</p><h2>MAGIC LINK EMAIL</h2>
{#if data.emailIdentity}<div class="verified-email"><div><b>{data.emailIdentity.email}</b><small>VERIFIED EMAIL IDENTITY</small></div><span>VERIFIED</span></div><PendingForm action="?/removeEmailIdentity"><button class="secondary console-action" disabled={data.authenticationMethods.total <= 1}>REMOVE MAGIC-LINK EMAIL</button></PendingForm>{/if}
<PendingForm action="?/requestEmailVerification"><label class="field">{data.emailIdentity ? 'CHANGE VERIFIED EMAIL' : 'EMAIL ADDRESS'}<input name="email" type="email" value={data.emailIdentity?.email ?? ''} autocomplete="email" required /></label><small class="identity-note">We will email a verification link before this address can be used to sign in.</small>{#if form?.emailError}<p class="error">{form.emailError}</p>{/if}{#if form?.emailVerificationSent}<p class="success">Verification link sent. It expires in 15 minutes.</p>{/if}{#if data.emailVerified}<p class="success">Email verified. Magic-link sign-in is now active.</p>{/if}<button class="secondary console-action" disabled={!data.emailDeliveryConfigured}>SEND VERIFICATION LINK →</button></PendingForm>
{:else if accessPanel === 'local'}
<p class="eyebrow">OPTIONAL FALLBACK</p><h2>USERNAME &amp; PASSWORD</h2>
{#if data.hasLocalCredentials}
<PendingForm action="?/changeLoginUsername"><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={user.loginUsername ?? ''} 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/><PendingForm action="?/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>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm>
<details class="method-removal"><summary>REMOVE USERNAME &amp; PASSWORD</summary><p>This removes both local credentials. Your public display name is unaffected.</p><PendingForm action="?/removeLocalCredentials"><label class="field">CURRENT PASSWORD<input name="password" type="password" autocomplete="current-password" required /></label>{#if form?.localCredentialError}<p class="error">{form.localCredentialError}</p>{/if}<button class="danger console-action" disabled={data.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="?/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>{#if form?.localCredentialError}<p class="error">{form.localCredentialError}</p>{/if}{#if form?.localCredentialsAdded}<p class="success">Username and password sign-in added.</p>{/if}<button class="secondary console-action">ADD PASSWORD FALLBACK →</button></PendingForm>
{/if}
{:else}
<p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN PROVIDERS</h2>
<div class="provider-stack">
{#if data.discordConnection}
<div class="connection-card discord-connection linked-connection provider-connection-card" class:confirming={discordUnlinkConfirm}>
<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>
<button class="connection-state provider-unlink-trigger" disabled={!data.discordConnection.canUnlink} aria-label="Unlink Discord" onclick={() => discordUnlinkConfirm = true}><span>LINKED</span><span>UNLINK</span></button>
{#if discordUnlinkConfirm}
<div class="provider-unlink-confirm"><div><b>UNLINK DISCORD?</b><p>Disconnect this identity from your SHD.build account?</p></div><div><button class="secondary" onclick={() => discordUnlinkConfirm = false}>CANCEL</button><PendingForm action="?/unlinkDiscord"><button class="primary">CONFIRM UNLINK</button></PendingForm></div></div>
{/if}
{#if !data.discordConnection.canUnlink}<p class="connection-warning provider-card-message">Discord is your only sign-in method. Add a passkey, verified email, or password before unlinking.</p>{/if}
{#if form?.discordUnlinkError}<p class="error provider-card-message">{form.discordUnlinkError}</p>{/if}
</div>
{: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 class="provider-placeholder"><span class="provider-monogram google-provider">G</span><div><b>GOOGLE</b><small>ACCOUNT LINKING PLACEHOLDER</small></div><em>PLANNED</em></div>
<div class="provider-placeholder"><span class="provider-monogram apple-provider">A</span><div><b>APPLE</b><small>ACCOUNT LINKING PLACEHOLDER</small></div><em>PLANNED</em></div>
<div class="provider-placeholder"><span class="provider-monogram steam-provider">S</span><div><b>STEAM</b><small>ACCOUNT LINKING PLACEHOLDER</small></div><em>PLANNED</em></div>
</div>
{/if}
</div>
{:else}
<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 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>Immediately delete retained login events and their IP, device, session, and failure metadata instead of waiting for normal retention cleanup.</small></span></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}
</aside>
</div>
</main>
<style>
.profile-notification-list a > div { grid-template-columns: 30px minmax(0, 1fr); align-items: center; }
.profile-notification-list a > div > img { width: 30px; height: 30px; object-fit: cover; border: 1px solid #3b4446; border-radius: 50%; background: #111; }
.profile-notification-list a > div > span { min-width: 0; display: grid; gap: 3px; }
.profile-notification-list a.isac-message b { color: #8ecce5; }
.profile-notification-list a.positive { border-left: 2px solid var(--shd-success); background: #101713; }
.profile-notification-list a.negative { border-left: 2px solid var(--shd-danger); background: #191111; }
.profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; }
.profile-notification-list a.positive em { color: var(--shd-success); }
.profile-notification-list a.negative em { color: var(--shd-danger); }
.profile-public-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.profile-public-actions a{min-height:28px;display:inline-flex;align-items:center;padding:4px 7px;border:1px solid var(--shd-border);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.profile-public-actions a:hover,.profile-public-actions a:focus-visible{border-color:var(--shd-orange);background:#211a0e}
.profile-visibility-control{display:grid;gap:9px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:#0d1011}
.profile-visibility-control>div{display:grid;gap:4px}.profile-visibility-control span,.profile-visibility-control strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.profile-visibility-control span{color:var(--shd-text-muted)}.profile-visibility-control strong{color:var(--shd-info)}.profile-visibility-control p,.profile-visibility-control small{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.profile-visibility-control small{font-family:var(--shd-font-editorial)}
.profile-visibility-control :global(form button){width:100%;min-height:40px}
@media(max-width:600px){.profile-public-actions a,.profile-visibility-control :global(form button){min-height:44px}}
</style>
+312 -47
View File
@@ -14,6 +14,16 @@ import { randomUUID } from 'node:crypto';
import { queueCurrentAvatarReview } from '$lib/server/avatars';
import { agentProfileHref } from '$lib/agents';
import { resolveAgentProfile } from '$lib/server/agent-profiles';
import {
orderProfileCommentThread,
profileCommentNotificationRecipient
} from '$lib/server/profile-comment-thread';
import {
notifyProfileCommentFollowers,
profileCommentConversation
} from '$lib/server/profile-comment-follows';
import { loadAccountControlData } from '$lib/server/account-control-data';
import { saveProfileBio, updateProfileVisibility } from '$lib/server/profile-settings';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
@@ -24,17 +34,23 @@ const unavailable = (row: Record<string, unknown> | null | undefined) => {
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
};
const commentHref = (targetType: string, slug: unknown, targetId: string, targetPublicSlug: unknown) => {
const commentHref = (
targetType: string,
slug: unknown,
targetId: string,
targetPublicSlug: unknown,
commentId: string
) => {
if (targetType === 'profile') {
return agentProfileHref({
id: targetId,
publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
}, 'profile-comments');
}, `profile-comment-${commentId}`);
}
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comment-${encodeURIComponent(commentId)}`;
};
export const load: PageServerLoad = async ({ params, cookies }) => {
export const load: PageServerLoad = async ({ params, cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const resolved = await resolveAgentProfile(database, params.id);
@@ -43,7 +59,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
const agent = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,
profile.public_slug,profile.bio_markdown,profile.is_public,profile.bio_updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
status.blocked_at,status.banned_until,deleted.deleted_at
FROM users
@@ -61,6 +77,18 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
if (params.id !== String(agent.public_slug)) {
redirect(308, agentProfileHref({ id: agentId, publicSlug: String(agent.public_slug) }));
}
const [pendingBioRevision, accountControl] = isSelf
? await Promise.all([
(await database.prepare(`SELECT revision.body_markdown,revision.created_at,scan.reason
FROM moderated_content_revisions revision
JOIN content_moderation_scans scan ON scan.revision_id=revision.id
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
WHERE revision.content_type='profile_bio' AND revision.content_id=?
AND scan.status IN ('review','priority_review','error') AND review.id IS NULL
ORDER BY revision.created_at DESC LIMIT 1`)).get(agentId),
loadAccountControlData(database, agentId, url.searchParams.get('control'))
])
: [null, null];
const [roleRows, buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
await Promise.all([
@@ -76,7 +104,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
FROM builds WHERE owner_id=? AND visibility='public'
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
(await database.prepare(`SELECT comments.id,comments.body_markdown,comments.created_at,
(await database.prepare(`SELECT comments.id,comments.created_at,
thread.target_type,thread.target_id,builds.title AS build_title,builds.slug,
COALESCE(target_profile.display_name,target_user.username) AS target_display_name,
target_profile.public_slug AS target_public_slug
@@ -94,8 +122,9 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)
ORDER BY comments.created_at DESC LIMIT 50`)).all(agentId, new Date().toISOString()),
(await database.prepare(`SELECT comments.id,comments.author_id,comments.body_markdown,
comments.created_at,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
(await database.prepare(`SELECT comments.id,comments.author_id,comments.parent_id,comments.body_markdown,
comments.status,comments.created_at,
COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
profile.public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
FROM comment_threads thread
@@ -128,6 +157,86 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
FROM builds WHERE owner_id=? AND visibility='public'`)).get(agentId)
]);
const loadedCommentIds = new Set(profileCommentRows.map((comment) => String(comment.id)));
let missingParentIds = [...new Set(profileCommentRows
.map((comment) => comment.parent_id ? String(comment.parent_id) : '')
.filter((parentId) => parentId && !loadedCommentIds.has(parentId)))];
for (let level = 0; missingParentIds.length && level < 100; level += 1) {
const placeholders = missingParentIds.map(() => '?').join(',');
const ancestors = await (await database.prepare(`SELECT comments.id,comments.author_id,
comments.parent_id,comments.body_markdown,comments.status,comments.created_at,
COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
profile.public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,
avatar.updated_at AS avatar_updated_at
FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN users ON users.id=comments.author_id
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
WHERE comments.id IN (${placeholders})
AND comments.status IN ('visible','removed')
AND thread.target_type='profile' AND thread.target_id=?`))
.all(...missingParentIds, agentId);
if (!ancestors.length) break;
profileCommentRows.push(...ancestors);
for (const ancestor of ancestors) loadedCommentIds.add(String(ancestor.id));
missingParentIds = [...new Set(ancestors
.map((ancestor) => ancestor.parent_id ? String(ancestor.parent_id) : '')
.filter((parentId) => parentId && !loadedCommentIds.has(parentId)))];
}
const commentAuthorIds = [...new Set(profileCommentRows
.map((comment) => comment.author_id ? String(comment.author_id) : '')
.filter(Boolean))];
const commentRoleRows = commentAuthorIds.length
? await (await database.prepare(`SELECT user_roles.user_id,roles.name
FROM user_roles
JOIN roles ON roles.id=user_roles.role_id
WHERE user_roles.user_id IN (${commentAuthorIds.map(() => '?').join(',')})
ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(...commentAuthorIds)
: [];
const rolesByCommentAuthor = new Map<string, string[]>();
for (const row of commentRoleRows) {
const userId = String(row.user_id);
const roles = rolesByCommentAuthor.get(userId) ?? [];
roles.push(String(row.name));
rolesByCommentAuthor.set(userId, roles);
}
const threadedProfileComments = orderProfileCommentThread(profileCommentRows.map((comment) => {
const status = String(comment.status) === 'removed' ? 'removed' as const : 'visible' as const;
return {
id: String(comment.id),
parentId: comment.parent_id ? String(comment.parent_id) : null,
status,
bodyHtml: status === 'visible' ? renderUserMarkdown(comment.body_markdown) : '',
createdAt: String(comment.created_at),
author: {
id: String(comment.author_id),
displayName: String(comment.display_name),
publicSlug: comment.public_slug ? String(comment.public_slug) : null,
avatarUrl: avatarUrl(
comment.avatar_source,
comment.avatar_value,
String(comment.author_id),
comment.avatar_updated_at
),
roles: rolesByCommentAuthor.get(String(comment.author_id)) ?? []
},
canDelete: status === 'visible' && viewer?.id === String(comment.author_id),
canReport: status === 'visible' && Boolean(viewer) && viewer?.id !== String(comment.author_id)
};
}));
const followedRootIds = viewer && threadedProfileComments.length
? new Set((await (await database.prepare(`SELECT root_comment_id
FROM comment_thread_follows
WHERE user_id=? AND root_comment_id IN (${[...new Set(threadedProfileComments.map((comment) => comment.rootId))]
.map(() => '?').join(',')})`)).all(
viewer.id,
...[...new Set(threadedProfileComments.map((comment) => comment.rootId))]
)).map((row) => String(row.root_comment_id)))
: new Set<string>();
return {
agent: {
id: String(agent.id),
@@ -140,6 +249,16 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
agent.avatar_updated_at
),
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown),
bioMarkdown: isSelf
? String(pendingBioRevision?.body_markdown ?? agent.bio_markdown ?? '')
: '',
isPublic: Boolean(agent.is_public),
bioHidden: Boolean(agent.bio_hidden),
bioModerationReason: isSelf && agent.moderation_reason
? String(agent.moderation_reason)
: null,
bioReviewPending: Boolean(pendingBioRevision),
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
roles: roleRows.map((role) => String(role.name))
},
counts: {
@@ -167,58 +286,109 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
const targetId = String(comment.target_id);
return {
id: String(comment.id),
bodyHtml: renderUserMarkdown(comment.body_markdown),
createdAt: String(comment.created_at),
targetType,
targetTitle: targetType === 'profile'
? `${String(comment.target_display_name ?? 'Agent')} profile`
: String(comment.build_title ?? 'Community content'),
targetHref: commentHref(targetType, comment.slug, targetId, comment.target_public_slug)
targetHref: commentHref(
targetType,
comment.slug,
targetId,
comment.target_public_slug,
String(comment.id)
)
};
}),
profileComments: profileCommentRows.map((comment) => ({
id: String(comment.id),
bodyHtml: renderUserMarkdown(comment.body_markdown),
createdAt: String(comment.created_at),
author: {
id: String(comment.author_id),
displayName: String(comment.display_name),
publicSlug: comment.public_slug ? String(comment.public_slug) : null,
avatarUrl: avatarUrl(
comment.avatar_source,
comment.avatar_value,
String(comment.author_id),
comment.avatar_updated_at
)
},
canDelete: viewer?.id === String(comment.author_id),
canReport: Boolean(viewer) && viewer?.id !== String(comment.author_id)
profileComments: threadedProfileComments.map((comment) => ({
...comment,
isFollowing: followedRootIds.has(comment.rootId)
})),
viewer: {
id: viewer?.id ?? null,
canComment: Boolean(viewer) && Boolean(agent.is_public),
canReportProfile: Boolean(viewer) && !isSelf,
isSelf
}
},
accountControl
};
};
export const actions: Actions = {
saveBio: async ({ params, cookies, request }) => {
const viewer = await getSessionUser(cookies);
if (!viewer) return fail(401, { bioError: 'Sign in to edit your profile.', bioResultToken: randomUUID() });
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner || owner.id !== viewer.id) {
return fail(403, { bioError: 'You can edit only your own profile.', bioResultToken: randomUUID() });
}
const result = await saveProfileBio(viewer, (await request.formData()).get('bio'));
if (!result.ok) {
return fail(400, { bioError: result.error, bioResultToken: randomUUID() });
}
return {
bioSaved: true,
bioReviewQueued: result.reviewQueued,
bioMarkdown: result.bio,
bioResultToken: randomUUID()
};
},
setProfileVisibility: async ({ params, cookies, request }) => {
const viewer = await getSessionUser(cookies);
if (!viewer) return fail(401, {
profileVisibilityError: 'Sign in to change profile visibility.',
profileVisibilityResultToken: randomUUID()
});
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner || owner.id !== viewer.id) {
return fail(403, {
profileVisibilityError: 'You can change only your own profile.',
profileVisibilityResultToken: randomUUID()
});
}
const result = await updateProfileVisibility(
viewer,
(await request.formData()).get('isPublic')
);
if (!result.ok) {
return fail(400, {
profileVisibilityError: result.error,
profileVisibilityResultToken: randomUUID()
});
}
return {
profileVisibilitySaved: true,
profileIsPublic: result.isPublic,
profileVisibilityResultToken: randomUUID()
};
},
commentProfile: async ({ params, cookies, request }) => {
const commenter = await getSessionUser(cookies);
if (!commenter) return fail(401, { profileCommentError: 'Sign in to post a profile comment.' });
const formData = await request.formData();
const parentId = String(formData.get('parentId') ?? '').trim() || null;
const commentFailure = (status: number, profileCommentError: string) => fail(status, {
profileCommentError,
profileCommentParentId: parentId,
profileCommentResultToken: randomUUID()
});
if (!commenter) return commentFailure(401, 'Sign in to post a profile comment.');
const body = normalizeUserMarkdown(
(await request.formData()).get('body'),
formData.get('body'),
COMMENT_MAX_CHARACTERS
);
if (body === null || Array.from(body).length < 2) {
return fail(400, {
profileCommentError: `Comments must contain 2–${COMMENT_MAX_CHARACTERS.toLocaleString()} visible characters.`
});
return commentFailure(
400,
`Comments must contain 2–${COMMENT_MAX_CHARACTERS.toLocaleString()} visible characters.`
);
}
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
if (!owner) return commentFailure(404, 'Agent profile not found.');
const agentId = owner.id;
const moderationPolicy = await getCommunityModerationPolicy();
const now = new Date();
@@ -233,10 +403,10 @@ export const actions: Actions = {
const commenterState = rows.find((row) => row.id === commenter.id);
const ownerState = rows.find((row) => row.id === agentId);
if (unavailable(commenterState)) {
return fail(403, { profileCommentError: 'This account cannot post comments.' });
return commentFailure(403, 'This account cannot post comments.');
}
if (unavailable(ownerState) || !Boolean(ownerState?.is_public)) {
return fail(404, { profileCommentError: 'Agent profile not found.' });
return commentFailure(404, 'Agent profile not found.');
}
const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at
@@ -247,10 +417,10 @@ export const actions: Actions = {
if (typeof recent?.created_at === 'string') {
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
if (elapsed < RAPID_COMMENT_SECONDS) {
return fail(429, { profileCommentError: 'Wait a few seconds before posting again.' });
return commentFailure(429, 'Wait a few seconds before posting again.');
}
if (elapsed < moderationPolicy.duplicateWindowSeconds && recent.body_markdown === body) {
return fail(409, { profileCommentError: 'That comment was already posted.' });
return commentFailure(409, 'That comment was already posted.');
}
}
@@ -268,41 +438,133 @@ export const actions: Actions = {
const thread = await (await database.prepare(
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
)).get(agentId);
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
if (!thread?.id) return commentFailure(500, 'The profile thread could not be opened.');
const parent = parentId
? await (await database.prepare(`SELECT parent.author_id
FROM comments parent
JOIN comment_threads thread ON thread.id=parent.thread_id
WHERE parent.id=? AND parent.status='visible' AND thread.id=?
AND thread.target_type='profile' AND thread.target_id=?`))
.get(parentId, thread.id, agentId)
: null;
if (parentId && !parent) {
return commentFailure(404, 'That message is no longer available for replies.');
}
const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments
const inserted = parentId
? await (await database.prepare(`INSERT INTO comments
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
SELECT ?,parent.thread_id,?,parent.id,?,?,?,?
FROM comments parent
JOIN comment_threads thread ON thread.id=parent.thread_id
WHERE parent.id=? AND parent.status='visible' AND thread.id=?
AND thread.target_type='profile' AND thread.target_id=?`))
.run(
commentId,
commenter.id,
body,
commentStatus,
createdAt,
createdAt,
parentId,
thread.id,
agentId
)
: await (await database.prepare(`INSERT INTO comments
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
VALUES(?,?,?,NULL,?,?,?,?)`))
.run(commentId, thread.id, commenter.id, body, commentStatus, createdAt, createdAt);
if (!inserted.changes) {
return commentFailure(409, 'That conversation changed before the reply could be posted.');
}
await recordCommunityModerationScan({
contentType: 'comment',
contentId: commentId,
authorId: commenter.id,
result: moderation
});
if (commentStatus === 'visible' && commenter.id !== agentId) {
const profileLink = agentProfileHref(owner, 'profile-comments');
const parentAuthorId = parent?.author_id ? String(parent.author_id) : null;
const recipientId = profileCommentNotificationRecipient(
commenter.id,
parentAuthorId,
agentId
);
if (commentStatus === 'visible' && recipientId) {
const profileLink = agentProfileHref(owner, `profile-comment-${commentId}`);
const isDirectReplyRecipient = Boolean(parentAuthorId && recipientId === parentAuthorId);
await createNotification({
userId: agentId,
userId: recipientId,
category: 'comments',
source: 'agent',
title: 'New profile comment',
body: `${commenter.displayName} posted a comment on your Agent profile.`,
title: parentId ? 'New profile reply' : 'New profile comment',
body: parentId
? isDirectReplyRecipient
? `${commenter.displayName} replied to your message on ${owner.displayName}'s Agent profile.`
: `${commenter.displayName} replied in a conversation on your Agent profile.`
: `${commenter.displayName} posted a comment on your Agent profile.`,
link: profileLink,
actions: [{
label: 'VIEW COMMENT',
label: parentId ? 'VIEW REPLY' : 'VIEW COMMENT',
href: profileLink
}]
});
}
if (commentStatus === 'visible' && parentId) {
await notifyProfileCommentFollowers({
commentId,
authorId: commenter.id,
authorDisplayName: commenter.displayName,
profileOwnerId: agentId,
profileOwnerDisplayName: owner.displayName,
profileOwnerPublicSlug: owner.publicSlug,
directRecipientId: recipientId
});
}
return {
profileCommentSaved: true,
profileCommentReviewQueued: commentStatus === 'pending_review',
profileCommentId: commentId,
profileCommentParentId: parentId,
profileCommentResultToken: randomUUID()
};
},
toggleProfileCommentFollow: async ({ params, cookies, request }) => {
const viewer = await getSessionUser(cookies);
const formData = await request.formData();
const commentId = String(formData.get('commentId') ?? '').trim();
const intent = String(formData.get('intent') ?? '').trim();
const failure = (status: number, message: string) => fail(status, {
profileCommentFollowError: message,
profileCommentFollowId: commentId,
profileCommentFollowResultToken: randomUUID()
});
if (!viewer) return failure(401, 'Sign in to follow a conversation.');
if (!commentId || !['follow', 'unfollow'].includes(intent)) {
return failure(400, 'Choose a conversation and follow preference.');
}
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return failure(404, 'Agent profile not found.');
const conversation = await profileCommentConversation(commentId, owner.id);
if (!conversation) return failure(404, 'That conversation is no longer available.');
if (intent === 'follow') {
await (await database.prepare(`INSERT OR IGNORE INTO comment_thread_follows
(user_id,root_comment_id,created_at) VALUES(?,?,?)`))
.run(viewer.id, conversation.rootId, new Date().toISOString());
} else {
await (await database.prepare(
'DELETE FROM comment_thread_follows WHERE user_id=? AND root_comment_id=?'
)).run(viewer.id, conversation.rootId);
}
return {
profileCommentFollowSaved: true,
profileCommentFollowing: intent === 'follow',
profileCommentFollowId: commentId,
profileCommentFollowResultToken: randomUUID()
};
},
deleteProfileComment: async ({ params, cookies, request }) => {
const viewer = await getSessionUser(cookies);
if (!viewer) return fail(401, { profileCommentError: 'Sign in to remove a comment.' });
@@ -320,9 +582,12 @@ export const actions: Actions = {
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
}
const removedAt = new Date().toISOString();
await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,removed_by=?,
const removed = await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,removed_by=?,
removal_reason='Removed by author',updated_at=? WHERE id=? AND status='visible'`))
.run(removedAt, viewer.id, removedAt, commentId);
if (!removed.changes) {
return fail(409, { profileCommentError: 'That comment changed before it could be removed.' });
}
return {
profileCommentDeleted: true,
profileCommentId: commentId,
+238 -87
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import AccountControl from '$lib/components/AccountControl.svelte';
import CommentComposer from '$lib/components/CommentComposer.svelte';
import ContentReport from '$lib/components/ContentReport.svelte';
import InfoPopover from '$lib/components/InfoPopover.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import OverflowMenu from '$lib/components/OverflowMenu.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
import { tick } from 'svelte';
type PublicAgent = {
id: string;
@@ -14,6 +18,12 @@
publicSlug: string;
avatarUrl: string;
bioHtml: string;
bioMarkdown: string;
isPublic: boolean;
bioHidden: boolean;
bioModerationReason: string | null;
bioReviewPending: boolean;
bioReviewReason: string | null;
roles: string[];
};
type PublicContent = {
@@ -25,7 +35,6 @@
};
type AuthoredComment = {
id: string;
bodyHtml: string;
createdAt: string;
targetType: string;
targetTitle: string;
@@ -33,11 +42,17 @@
};
type ProfileComment = {
id: string;
parentId: string | null;
status: 'visible' | 'removed';
bodyHtml: string;
createdAt: string;
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string };
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string; roles: string[] };
canDelete: boolean;
canReport: boolean;
depth: number;
hasReplies: boolean;
rootId: string;
isFollowing: boolean;
};
type PageData = {
agent: PublicAgent;
@@ -47,12 +62,22 @@
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
accountControl: any | null;
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let profileCommentDraft = $state('');
let activeComposerId = $state<string | null>(null);
let deletingCommentId = $state('');
let activeCommentMenuId = $state('');
let bioEditorOpen = $state(false);
let bioDraft = $state('');
let bioDraftInitialized = $state(false);
let handledBioToken = $state('');
let handledCommentToken = $state('');
const resultComposerId = $derived(String(form?.profileCommentParentId ?? '') || 'root');
const notificationIsIsac = (notification: any) =>
notification.category === 'isac' || String(notification.source).startsWith('ISAC');
const dateLabel = (value: string) => {
const date = new Date(value);
@@ -65,11 +90,70 @@
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
if (!token || token === handledCommentToken) return;
handledCommentToken = token;
if (form?.profileCommentSaved || form?.commentSaved) profileCommentDraft = '';
if (form?.profileCommentSaved || form?.commentSaved) {
profileCommentDraft = '';
activeComposerId = null;
}
if (form?.profileCommentDeleted || form?.commentDeleted) deletingCommentId = '';
if (form?.profileCommentFollowSaved) activeCommentMenuId = '';
});
$effect(() => {
if (!bioDraftInitialized) {
bioDraft = data.agent.bioMarkdown;
bioDraftInitialized = true;
}
const token = String(form?.bioResultToken ?? '');
if (!token || token === handledBioToken) return;
handledBioToken = token;
if (form?.bioSaved) {
bioDraft = String(form.bioMarkdown ?? bioDraft);
bioEditorOpen = false;
}
if (form?.bioError) bioEditorOpen = true;
});
$effect(() => {
if (form?.profileCommentError) activeComposerId = resultComposerId;
});
async function openComposer(id: string) {
activeComposerId = activeComposerId === id ? null : id;
profileCommentDraft = '';
deletingCommentId = '';
activeCommentMenuId = '';
if (!activeComposerId) return;
await tick();
document.getElementById(`profile-comment-editor-${activeComposerId}`)?.focus();
}
</script>
{#snippet commentComposer(parentId: string | null, label: string)}
<PendingForm action="?/commentProfile" class="profile-comment-form">
<input type="hidden" name="agentId" value={data.agent.id}/>
{#if parentId}<input type="hidden" name="parentId" value={parentId}/>{/if}
<CommentComposer
id={`profile-comment-editor-${parentId ?? 'root'}`}
name="body"
{label}
maxLength={2000}
rows={4}
required
bind:value={profileCommentDraft}
/>
<ModerationDisclosure compact/>
{#if resultComposerId === (parentId ?? 'root') && (form?.profileCommentError || form?.commentError)}
<p class="error">{form?.profileCommentError ?? form?.commentError}</p>
{/if}
<div class="profile-comment-form-actions">
<button type="button" class="secondary" onclick={() => openComposer(parentId ?? 'root')}>CANCEL</button>
<button class="primary" disabled={!profileCommentDraft.trim()}>
{parentId ? 'POST REPLY →' : 'POST MESSAGE →'}
</button>
</div>
</PendingForm>
{/snippet}
<svelte:head>
<title>{data.agent.displayName} — Agent Profile — SHD.build</title>
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, guides, and community activity on SHD.build.`}/>
@@ -80,12 +164,19 @@
<header class="agent-command">
<div class="agent-command-identity">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<RoleBadgeList roles={data.agent.roles} />
<AgentIdentityLink agent={data.agent} size="large" roles={data.agent.roles} />
</div>
<div class="agent-command-actions">
{#if data.viewer.canReportProfile}<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>{/if}
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
{#if data.viewer.isSelf}
<PendingForm action="?/setProfileVisibility" toast={false} class="profile-visibility-form">
<input type="hidden" name="isPublic" value={data.agent.isPublic ? '0' : '1'}/>
<button class="visibility-toggle" class:enabled={data.agent.isPublic} role="switch" aria-checked={data.agent.isPublic} aria-label={`Public profile ${data.agent.isPublic ? 'enabled' : 'hidden'}. Toggle visibility.`}><span aria-hidden="true"></span><b>{data.agent.isPublic ? 'PUBLIC' : 'HIDDEN'}</b></button>
</PendingForm>
<InfoPopover label="About profile visibility" title="PROFILE VISIBILITY" text="A hidden profile leaves your name and avatar on public builds, guides, and comments, but removes the profile from discovery and blocks new profile messages."/>
<a class="mobile-account-jump" href="#account-control">ACCOUNT CONTROL ↓</a>
{:else if data.viewer.canReportProfile}
<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>
{/if}
</div>
</header>
@@ -97,93 +188,81 @@
</section>
<div class="agent-profile-grid">
<div class="agent-main-column">
<div class="agent-primary">
<section class="profile-section agent-bio">
<header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><div class="profile-header-actions"><span>PUBLIC BIO</span>{#if data.viewer.canReportProfile && data.agent.bioHtml}<ContentReport contentType="profile_bio" contentId={data.agent.id} label="REPORT BIO" result={form}/>{/if}</div></header>
<header>
<div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div>
<div class="profile-header-actions">
{#if data.viewer.isSelf}
<button type="button" class="bio-edit-trigger" aria-expanded={bioEditorOpen} onclick={() => bioEditorOpen = !bioEditorOpen}>{bioEditorOpen ? 'CLOSE EDITOR' : 'EDIT BIO'}</button>
{:else}
<span>PUBLIC BIO</span>
{#if data.viewer.canReportProfile && data.agent.bioHtml}<ContentReport contentType="profile_bio" contentId={data.agent.id} label="REPORT BIO" result={form}/>{/if}
{/if}
</div>
</header>
{#if data.viewer.isSelf && data.agent.bioHidden}
<div class="profile-owner-notice error" role="status"><strong>BIO HIDDEN BY OVERSIGHT</strong><span>{data.agent.bioModerationReason ?? 'Review your notification inbox for details.'}</span></div>
{/if}
{#if data.viewer.isSelf && data.agent.bioReviewPending}
<div class="profile-owner-notice" role="status"><strong>BIO REVISION AWAITING REVIEW</strong><span>{data.agent.bioReviewReason ?? 'Your last approved biography remains public during review.'}</span></div>
{/if}
{#if form?.profileVisibilityError}<p class="error owner-profile-result">{form.profileVisibilityError}</p>{/if}
{#if form?.profileVisibilitySaved}<p class="success owner-profile-result">Profile visibility updated.</p>{/if}
{#if data.viewer.isSelf && bioEditorOpen}
<PendingForm action="?/saveBio" class="profile-bio-form">
<MarkdownEditor name="bio" label="AGENT BIO" help="Describe your playstyle, preferred roles, community work, or current objectives. Links are allowed." maxLength={4000} rows={12} bind:value={bioDraft}/>
<ModerationDisclosure/>
{#if form?.bioError}<p class="error">{form.bioError}</p>{/if}
<div class="profile-bio-actions"><button type="button" class="secondary" onclick={() => bioEditorOpen = false}>CANCEL</button><button class="primary">SAVE PUBLIC BIO →</button></div>
</PendingForm>
{:else}
<div class="bio-content">
<MarkdownContent html={data.agent.bioHtml} emptyMessage="This Agent has not published a field bio yet." />
</div>
{/if}
</section>
<div class="agent-published-grid">
<section class="profile-section">
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
<div class="public-content-grid">
{#each data.builds as build}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}>
<span>LOADOUT</span>
<h3>{build.title}</h3>
<p>{build.summary || 'Open this loadout to inspect its equipment, Attributes, and calculated performance.'}</p>
<footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC BUILDS</strong>
<p>This Agent has not shared a public loadout.</p>
</div>
{/each}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>{build.summary || 'Open this loadout to inspect its equipment, Attributes, and calculated performance.'}</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLIC BUILDS</strong><p>This Agent has not shared a public loadout.</p></div>{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">FIELD GUIDANCE</p><h2>PUBLISHED GUIDES</h2></div><span>{data.counts.guides} TOTAL</span></header>
<div class="public-content-grid">
{#each data.guides as guide}
<a class="public-content-card guide-card" href={`/builds/${encodeURIComponent(guide.slug)}`}>
<span>GUIDE</span>
<h3>{guide.title}</h3>
<p>{guide.summary || 'Open this guide for the complete strategy and field notes.'}</p>
<footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLISHED GUIDES</strong>
<p>This Agent has not published a guide.</p>
</div>
{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">COMMUNITY RECORD</p><h2>RECENT DISCUSSION</h2></div><span>{data.counts.authoredComments} COMMENTS</span></header>
<div class="authored-comment-list">
{#each data.authoredComments as comment}
<article>
<header><div><span>{comment.targetType.toUpperCase()}</span><strong>{comment.targetTitle}</strong></div><small>{dateLabel(comment.createdAt)}</small></header>
<MarkdownContent html={comment.bodyHtml} compact />
<a href={comment.targetHref}>OPEN DISCUSSION →</a>
</article>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC DISCUSSION</strong>
<p>Comments this Agent leaves on builds and guides will appear here.</p>
</div>
{/each}
<a class="public-content-card guide-card" href={`/builds/${encodeURIComponent(guide.slug)}`}><span>GUIDE</span><h3>{guide.title}</h3><p>{guide.summary || 'Open this guide for the complete strategy and field notes.'}</p><footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer></a>
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLISHED GUIDES</strong><p>This Agent has not published a guide.</p></div>{/each}
</div>
</section>
</div>
</div>
<aside class="profile-section profile-comms">
<header><div><p class="eyebrow">AGENT COMMS</p><h2>PROFILE MESSAGES</h2></div><span>{data.counts.profileComments}</span></header>
<section class="profile-comms" id="profile-comments">
<header class="profile-comms-heading">
<div><p class="eyebrow">AGENT COMMS</p><h2>PROFILE MESSAGES</h2></div>
<span>{data.counts.profileComments} {data.counts.profileComments === 1 ? 'MESSAGE' : 'MESSAGES'}</span>
</header>
{#if data.viewer.canComment}
<PendingForm action="?/commentProfile" class="profile-comment-form">
<input type="hidden" name="agentId" value={data.agent.id}/>
<MarkdownEditor
name="body"
label="LEAVE A MESSAGE"
help="Keep profile messages relevant, respectful, and useful to the Agent."
maxLength={2000}
rows={6}
required
bind:value={profileCommentDraft}
/>
<ModerationDisclosure/>
{#if form?.profileCommentError || form?.commentError}<p class="error">{form?.profileCommentError ?? form?.commentError}</p>{/if}
{#if form?.profileCommentSaved || form?.commentSaved}
<p class="success">{form?.profileCommentReviewQueued ? 'Profile message received and held for Oversight review.' : 'Profile message transmitted.'}</p>
<div class="profile-comment-start">
<button
type="button"
class="profile-comment-trigger"
aria-expanded={activeComposerId === 'root'}
aria-controls="profile-comment-composer-root"
onclick={() => openComposer('root')}
>{activeComposerId === 'root' ? 'CLOSE EDITOR' : 'LEAVE A MESSAGE'}</button>
</div>
{#if activeComposerId === 'root'}
<div id="profile-comment-composer-root">{@render commentComposer(null, 'NEW PROFILE MESSAGE')}</div>
{/if}
<button class="primary" disabled={!profileCommentDraft.trim()}>POST MESSAGE →</button>
</PendingForm>
{:else if !data.viewer.id}
<div class="comment-access-note"><strong>SIGN-IN REQUIRED</strong><p>Sign in to leave a message on this Agent profile.</p><a class="secondary" href="/sign-in">SIGN IN →</a></div>
{:else}
@@ -192,15 +271,55 @@
<div class="profile-comment-list">
{#each data.profileComments as comment}
<article>
<article
id={`profile-comment-${comment.id}`}
class="profile-comment"
class:reply={comment.depth > 0}
class:removed={comment.status === 'removed'}
style={`--thread-depth:${Math.min(comment.depth, 4)}`}
>
{#if comment.status === 'visible'}
<header>
<AgentIdentityLink agent={comment.author} size="compact" meta={dateLabel(comment.createdAt)} />
{#if comment.canDelete}
<button type="button" class="comment-remove" aria-expanded={deletingCommentId === comment.id} onclick={() => deletingCommentId = comment.id}>REMOVE</button>
{/if}
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form}/>{/if}
<AgentIdentityLink agent={comment.author} roles={comment.author.roles} meta={dateLabel(comment.createdAt)} />
</header>
<MarkdownContent html={comment.bodyHtml} compact />
<div class="comment-actions">
{#if data.viewer.canComment}
<button
type="button"
class="comment-reply"
aria-expanded={activeComposerId === comment.id}
aria-controls={`profile-comment-composer-${comment.id}`}
onclick={() => openComposer(comment.id)}
>REPLY</button>
{/if}
{#if data.viewer.id}
<OverflowMenu label={`Actions for ${comment.author.displayName}'s message`} bind:open={() => activeCommentMenuId === comment.id, (open) => activeCommentMenuId = open ? comment.id : ''}>
{#snippet children(close)}
<PendingForm action="?/toggleProfileCommentFollow" successMessage={comment.isFollowing ? 'Conversation unfollowed.' : 'Conversation followed.'}>
<input type="hidden" name="commentId" value={comment.id}/>
<input type="hidden" name="intent" value={comment.isFollowing ? 'unfollow' : 'follow'}/>
<button>{comment.isFollowing ? 'UNFOLLOW THREAD' : 'FOLLOW THREAD'}</button>
</PendingForm>
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form} plain menuItem/>{/if}
{#if comment.canDelete}
<button type="button" class="comment-remove danger" onclick={() => { deletingCommentId = comment.id; close(); }}>REMOVE MESSAGE</button>
{/if}
{/snippet}
</OverflowMenu>
{/if}
</div>
{:else}
<div class="removed-message">
<strong>MESSAGE REMOVED</strong>
<span>Replies remain visible to preserve the conversation.</span>
</div>
{/if}
{#if activeComposerId === comment.id}
<div id={`profile-comment-composer-${comment.id}`} class="reply-composer">
{@render commentComposer(comment.id, `REPLY TO ${comment.author.displayName.toUpperCase()}`)}
</div>
{/if}
{#if deletingCommentId === comment.id}
<div class="comment-delete-confirm" role="alert">
<p>Remove this profile message?</p>
@@ -221,24 +340,56 @@
</div>
{/each}
</div>
</section>
</div>
<aside class="agent-activity">
{#if data.viewer.isSelf && data.accountControl}
<div id="account-control"><AccountControl account={data.accountControl} displayName={data.agent.displayName} avatarUrl={data.agent.avatarUrl}/></div>
<section class="profile-section owner-notifications">
<header><div><p class="eyebrow">SHD COMMUNICATION NETWORK</p><h2>NOTIFICATIONS</h2></div><a href="/account/notifications">{data.accountControl.notificationUnread ? `${data.accountControl.notificationUnread} UNREAD` : 'VIEW INBOX'} →</a></header>
<div class="owner-notification-list">
{#each data.accountControl.notifications as notification}
<a class:unread={!notification.read_at} class:isac={notificationIsIsac(notification)} href="/account/notifications"><span><b>{notification.source}</b><small>{String(notification.category).toUpperCase()} · {dateLabel(String(notification.created_at))}</small></span><strong>{notification.title}</strong></a>
{:else}<div class="public-empty compact"><strong>INBOX CLEAR</strong><p>No unread notifications.</p></div>{/each}
</div>
</section>
{/if}
<section class="profile-section discussion-section">
<header><div><p class="eyebrow">COMMUNITY RECORD</p><h2>RECENT DISCUSSION</h2></div><span>{data.counts.authoredComments} COMMENTS</span></header>
<div class="authored-comment-list">
{#each data.authoredComments as comment}
<article>
<header><div><span>{comment.targetType.toUpperCase()}</span><strong>{comment.targetTitle}</strong></div><small>{dateLabel(comment.createdAt)}</small></header>
<a href={comment.targetHref}>OPEN DISCUSSION →</a>
</article>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC DISCUSSION</strong>
<p>Comments this Agent leaves on builds and guides will appear here.</p>
</div>
{/each}
</div>
</section>
</aside>
</div>
</main>
<style>
.public-agent-page{padding-top:28px}.agent-command{min-height:116px;display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-lg);margin-top:12px;padding:20px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.agent-command-identity{min-width:0;flex:1;display:grid;gap:12px}.agent-command :global(.agent-identity){min-width:0}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
.public-agent-page{padding-top:28px}.agent-command{position:static;z-index:auto;width:auto;height:auto;min-height:96px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:var(--shd-gap-lg);margin-top:12px;padding:14px 4px 16px;border:0;background:transparent}
.agent-command-identity{min-width:0}.agent-command :global(.agent-profile-identity){min-width:0}.agent-command-actions{display:flex;align-items:center;gap:8px}.agent-command-actions :global(.profile-visibility-form){margin:0}.agent-command-actions .visibility-toggle{min-height:40px}.mobile-account-jump{display:none}
.agent-telemetry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:8px;border:1px solid var(--shd-border);background:var(--shd-surface)}
.agent-telemetry>div{display:grid;gap:4px;padding:10px 12px;border-left:1px solid var(--shd-border)}.agent-telemetry>div:first-child{border-left:0}.agent-telemetry span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.agent-telemetry strong{color:var(--shd-orange);font-size:24px}
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(330px,.38fr);gap:10px;margin-top:10px}.agent-primary{display:grid;gap:10px;min-width:0}
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.42fr);align-items:start;gap:10px;margin-top:10px}.agent-main-column,.agent-primary,.agent-activity{display:grid;gap:10px;min-width:0}.agent-main-column{grid-column:1}.agent-activity{position:sticky;top:88px;grid-column:2}.agent-activity .public-empty{min-height:110px}#account-control{scroll-margin-top:88px}.agent-published-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.agent-published-grid .public-content-grid{grid-template-columns:1fr}
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.bio-content{min-height:110px;padding:18px}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card.guide-card{border-left-color:var(--shd-info)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.guide-card>span{color:var(--shd-info)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{display:flex;justify-content:space-between;gap:10px}.authored-comment-list article>header>div{display:grid;gap:3px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{color:var(--shd-text-muted)}.authored-comment-list article>a{justify-self:end}
.profile-comms{align-self:start;position:sticky;top:88px}:global(form.profile-comment-form){display:grid;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border)}:global(form.profile-comment-form > button){min-height:40px}.profile-comment-list{display:grid}.profile-comment-list>article{display:grid;gap:9px;padding:10px;border-bottom:1px solid var(--shd-border)}.profile-comment-list>article:last-child{border-bottom:0}.profile-comment-list article>header{display:flex;align-items:center;justify-content:space-between;gap:8px}.profile-comment-list article>header :global(.agent-identity){flex:1}.comment-remove{min-height:32px;padding:0 7px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.comment-remove:hover,.comment-remove:focus-visible{background:var(--shd-danger);color:var(--shd-bg)}.comment-delete-confirm{display:grid;gap:8px;padding:8px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{display:grid;gap:7px;padding:12px;border-bottom:1px solid var(--shd-border)}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}
.profile-header-actions{display:flex;align-items:center;gap:7px}.profile-header-actions>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.profile-comms{position:static}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{align-items:stretch;flex-direction:column}.agent-command-actions a{width:100%;min-height:44px;justify-content:center}.agent-telemetry{grid-template-columns:1fr}.agent-telemetry>div{border-top:1px solid var(--shd-border);border-left:0}.agent-telemetry>div:first-child{border-top:0}.public-content-grid{grid-template-columns:1fr}.profile-section>header{align-items:flex-start;flex-direction:column}:global(form.profile-comment-form > button),.comment-delete-confirm button{min-height:44px}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.comment-remove{transition:none}}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end}
.profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none}
.profile-header-actions{display:flex;align-items:center;gap:7px}.profile-header-actions>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.bio-edit-trigger{min-height:32px;padding:0 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-orange);background:var(--shd-surface-active)}.visibility-toggle{min-height:32px;display:flex;align-items:center;gap:7px;padding:0 8px;border:1px solid var(--shd-border-strong);background:#0d1011;color:var(--shd-text-muted);cursor:pointer}.visibility-toggle>span{position:relative;width:26px;height:14px;border:1px solid var(--shd-border-strong);background:#070909}.visibility-toggle>span::after{content:"";position:absolute;top:2px;left:2px;width:8px;height:8px;background:var(--shd-text-dim)}.visibility-toggle.enabled{border-color:#6b5128;color:var(--shd-orange)}.visibility-toggle.enabled>span::after{left:14px;background:var(--shd-orange)}.visibility-toggle b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.owner-notifications>header>a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.owner-notification-list{display:grid}.owner-notification-list>a{min-width:0;display:grid;gap:5px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text-muted)}.owner-notification-list>a:first-child{border-top:0}.owner-notification-list>a.unread{box-shadow:inset 2px 0 var(--shd-orange)}.owner-notification-list>a.isac{box-shadow:inset 2px 0 var(--shd-info)}.owner-notification-list span{min-width:0;display:flex;justify-content:space-between;gap:8px}.owner-notification-list b,.owner-notification-list small{font-size:var(--shd-type-telemetry);letter-spacing:.5px}.owner-notification-list b{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.owner-notification-list small{color:var(--shd-text-dim);white-space:nowrap}.owner-notification-list strong{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}
.profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
</style>
+5 -5
View File
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import type { Actions, PageServerLoad } from './$types';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
const validVisibility = new Set(['private', 'unlisted', 'public']);
const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
@@ -53,7 +54,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_talent_options ORDER BY talent_slot')).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(),
@@ -144,13 +145,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
iconUrl: iconUrl('brand_set', brand.id),
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
})),
weapons: weapons.map((weapon) => ({
weapons: attachWeaponCategoryBonuses(weapons.map((weapon) => ({
...weapon,
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
talent1Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 1).map((link) => String(link.talent_id)),
talent2Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 2).map((link) => String(link.talent_id)),
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
})),
talent2Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 2).map((link) => String(link.talent_id))
})), weaponBonuses),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
@@ -212,7 +212,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
native.damage_min,native.damage_max,native.reference_level,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
(SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count
(SELECT COUNT(*) FROM weapon_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count
FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
WHERE ${where}
+64 -10
View File
@@ -10,6 +10,8 @@ import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { agentProfileHref } from '$lib/agents';
import { profileCommentNotificationRecipient } from '$lib/server/profile-comment-thread';
import { notifyProfileCommentFollowers } from '$lib/server/profile-comment-follows';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
@@ -95,6 +97,12 @@ const queueTargetHref = (
? `/builds/${encodeURIComponent(String(slug))}`
: `/builds/${encodeURIComponent(String(contentId))}`;
}
if (String(targetType) === 'profile') {
return agentProfileHref({
id: String(targetId),
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
}, `profile-comment-${String(contentId)}`);
}
return targetHref(String(targetType), targetId, slug, publicSlug);
};
@@ -123,7 +131,10 @@ const assertQueuePermission = async (
const scanQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
(await database.prepare(`SELECT scan.*,revision.body_markdown AS revision_body,
comments.body_markdown AS comment_body,comments.status AS comment_status,
comments.thread_id,thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
comments.thread_id,comments.parent_id,parent_comment.author_id AS parent_author_id,
thread.target_type,thread.target_id,target_build.slug,
COALESCE(target_build.title,target_profile.display_name,target_user.username) AS target_title,
target_profile.public_slug AS target_public_slug,
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
target_build.owner_id AS guide_owner_id,
profile.bio_markdown AS current_bio,profile.bio_updated_at AS current_bio_updated_at,
@@ -133,10 +144,15 @@ const scanQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN comments parent_comment ON parent_comment.id=comments.parent_id
AND parent_comment.thread_id=comments.thread_id
LEFT JOIN builds target_build ON
(scan.content_type='guide' AND target_build.id=scan.content_id)
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN user_profiles target_profile ON scan.content_type='comment'
AND thread.target_type='profile' AND target_profile.user_id=thread.target_id
LEFT JOIN users target_user ON target_user.id=target_profile.user_id
LEFT JOIN user_profiles profile ON profile.user_id=scan.author_id
LEFT JOIN users ON users.id=scan.author_id
WHERE scan.id=? AND scan.content_type IN ('comment','profile_bio','guide')
@@ -634,7 +650,7 @@ export const actions: Actions = {
if (!body.trim()) return queueActionFailure(409, 'The queued content is no longer available.');
const now = new Date().toISOString();
const reviewId = randomUUID();
let targetOwnerId: string | null = null;
let commentRecipientId: string | null = null;
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
@@ -651,9 +667,16 @@ export const actions: Actions = {
SET status='visible',removed_at=NULL,removed_by=NULL,removal_reason=NULL,updated_at=?
WHERE id=? AND status='pending_review'`)).run(now, scan.content_id);
if (!result.changes) throw new Error('The pending comment changed before approval.');
targetOwnerId = String(scan.target_type) === 'profile'
const targetOwnerId = String(scan.target_type) === 'profile'
? String(scan.target_id)
: scan.guide_owner_id ? String(scan.guide_owner_id) : null;
: scan.guide_owner_id ? String(scan.guide_owner_id) : '';
commentRecipientId = targetOwnerId
? profileCommentNotificationRecipient(
String(scan.author_id),
scan.parent_author_id ? String(scan.parent_author_id) : null,
targetOwnerId
)
: null;
} else if (contentType === 'profile_bio') {
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
@@ -685,6 +708,7 @@ export const actions: Actions = {
policyVersion: Number(scan.policy_version),
provider: String(scan.provider),
model: String(scan.model),
parentId: scan.parent_id ? String(scan.parent_id) : null,
currentStatus: contentType === 'comment'
? String(scan.comment_status)
: contentType === 'profile_bio' ? 'revision_pending' : 'revision_pending'
@@ -714,7 +738,8 @@ export const actions: Actions = {
scan.content_id,
scan.target_type,
scan.target_id,
scan.slug
scan.slug,
scan.target_public_slug
);
await createNotification({
userId: authorId,
@@ -728,15 +753,41 @@ export const actions: Actions = {
actions: [{ label: 'VIEW CONTENT', href: link }],
mandatory: true
});
if (contentType === 'comment' && targetOwnerId && targetOwnerId !== authorId) {
if (contentType === 'comment' && commentRecipientId && commentRecipientId !== authorId) {
const isProfileReply = String(scan.target_type) === 'profile' && Boolean(scan.parent_id);
const isDirectReplyRecipient = Boolean(
scan.parent_author_id
&& commentRecipientId === String(scan.parent_author_id)
);
await createNotification({
userId: targetOwnerId,
userId: commentRecipientId,
category: 'comments',
source: 'agent',
title: 'New comment',
body: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
title: isProfileReply ? 'New profile reply' : 'New comment',
body: isProfileReply
? isDirectReplyRecipient
? `${String(scan.display_name ?? 'An Agent')} replied to your message on ${String(scan.target_title ?? 'an Agent')}'s Agent profile.`
: `${String(scan.display_name ?? 'An Agent')} replied in a conversation on your Agent profile.`
: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
link,
actions: [{ label: 'VIEW COMMENT', href: link }]
actions: [{ label: isProfileReply ? 'VIEW REPLY' : 'VIEW COMMENT', href: link }]
});
}
if (
contentType === 'comment'
&& String(scan.target_type) === 'profile'
&& Boolean(scan.parent_id)
) {
await notifyProfileCommentFollowers({
commentId: String(scan.content_id),
authorId,
authorDisplayName: String(scan.display_name ?? 'An Agent'),
profileOwnerId: String(scan.target_id),
profileOwnerDisplayName: String(scan.target_title ?? 'an Agent'),
profileOwnerPublicSlug: scan.target_public_slug
? String(scan.target_public_slug)
: null,
directRecipientId: commentRecipientId
});
}
return { communitySaved: true, communityResultToken: randomUUID() };
@@ -947,6 +998,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
parentId: comment.parent_id ? String(comment.parent_id) : null,
targetType: String(comment.target_type),
targetId: String(comment.target_id),
updatedAt: String(comment.updated_at)
@@ -1005,6 +1057,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
parentId: comment.parent_id ? String(comment.parent_id) : null,
removedAt: String(comment.removed_at),
removedBy: String(comment.removed_by),
removalReason: String(comment.removal_reason),
@@ -1026,6 +1079,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
parentId: comment.parent_id ? String(comment.parent_id) : null,
restorationReason: reason,
updatedAt: now
}
+16 -3
View File
@@ -76,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
@@ -85,11 +85,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute');
const talentOptionWithIcon = (talent: Record<string, any>) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
...talent,
id: talentId,
name: String(talent.name),
category: String(talent.category),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
};
return {
gear: [...pendingGearOnPage.map((item) => ({
...item,
@@ -109,7 +122,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const icon = brandIcons.find((item) => item.entity_id === brand.id);
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
})],
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))].map(talentOptionWithIcon),
augmentProfiles: slots.map((slot) => ({
slot,
requiredSlots: augmentSlotCount(slot),
+1 -1
View File
@@ -86,7 +86,7 @@
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
<SearchableAssignment items={gearTalents(editing.slot)} fieldName="talentIds" subjectLabel={`${editing.slot.toUpperCase()} TALENTS`} searchPlaceholder={`Search ${editing.slot.toLocaleLowerCase()} talents…`} emptySelectionLabel="NO TALENTS ASSIGNED" bind:selected={editing.talentIds}/>
<SearchableAssignment items={gearTalents(editing.slot)} presentation="visual-grid" fieldName="talentIds" bind:selected={editing.talentIds}/>
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
{/if}
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
+77 -40
View File
@@ -15,11 +15,13 @@ import {
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const categories: readonly string[] = WEAPON_CATEGORIES;
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const;
const modSlotTypes = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const;
const modSlotTypes = WEAPON_MOD_SLOT_TYPES;
type ExoticEffect = { statId: string | null; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
@@ -41,6 +43,15 @@ const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
async function bonusProfileSnapshot(category: string) {
const database = await getDatabase();
return (await (await database.prepare(`SELECT bonuses.id,bonuses.weapon_category,bonuses.stat_attribute_id,
attributes.name AS attribute_name,attributes.unit,bonuses.value,bonuses.sort_order,bonuses.updated_at
FROM weapon_category_attribute_bonuses bonuses
JOIN stat_attributes attributes ON attributes.id=bonuses.stat_attribute_id
WHERE bonuses.weapon_category=? ORDER BY bonuses.sort_order`)).all(category));
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
@@ -76,13 +87,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
(await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
(await database.prepare(`SELECT bonuses.*,attributes.name AS attribute_name,attributes.unit
FROM weapon_category_attribute_bonuses bonuses
JOIN stat_attributes attributes ON attributes.id=bonuses.stat_attribute_id
ORDER BY bonuses.weapon_category,bonuses.sort_order`)).all(),
(await database.prepare(`SELECT talents.id,talents.name FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
@@ -105,6 +119,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
])
]);
const pendingTalents = pending.filter((item) => item.kind === 'talent');
const talentOptionWithIcon = (talent: Record<string, any>) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
...talent,
id: talentId,
name: String(talent.name),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
};
const pendingWeapons = pending.filter((item) => item.kind === 'weapon').filter((item) =>
requestedWeaponId ? item.id === requestedWeaponId
: filter === 'standard' ? !item.isExotic
@@ -117,7 +142,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
damage_type: weapon.damageType,
is_exotic: weapon.isExotic ? 1 : 0,
reload_speed: weapon.reloadSpeed,
baseBonuses: [],
tiers: [],
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
@@ -150,7 +174,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)),
talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id),
baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category),
exoticTalent: talentId ? {
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
@@ -164,8 +188,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}}));
return {
weapons: [...pendingWeapons, ...configuredWeapons],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options],
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options].map(talentOptionWithIcon),
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options].map(talentOptionWithIcon),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
pagination,
@@ -173,6 +197,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
search,
sort,
requestedWeaponId
,bonusProfiles: categories.map((category) => ({
category,
effects: categoryBonuses.filter((effect) => effect.weapon_category === category)
}))
,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name }))
};
};
@@ -189,6 +217,43 @@ function readRange(form: FormData, tier: number, range: (typeof rangeNames)[numb
}
export const actions: Actions = {
saveBonusProfile: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const category = String(form.get('category') ?? '');
if (!categories.includes(category)) return fail(400, { bonusProfileError: 'Choose a valid weapon category.' });
const values = [1, 2, 3].map((order) => number(form, `bonusValue${order}`));
if (values.some((value) => !Number.isFinite(value) || value <= 0)) {
return fail(400, { bonusProfileError: 'Each category bonus requires a positive value.' });
}
const before = await bonusProfileSnapshot(category);
if (before.length !== 3) {
return fail(500, { bonusProfileError: `${category} requires exactly three shared bonus Attributes.` });
}
const database = await getDatabase();
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
for (let index = 0; index < before.length; index += 1) {
await (await database.prepare(
'UPDATE weapon_category_attribute_bonuses SET value=?,updated_at=? WHERE id=?'
)).run(values[index], now, before[index].id);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'weapon_bonus_profile.updated',
entityId: category,
before,
after: await bonusProfileSnapshot(category)
});
return { weaponBonusProfileSaved: true, weaponBonusProfileMode: 'edit', resultToken: randomUUID() };
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
@@ -212,8 +277,7 @@ export const actions: Actions = {
const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))];
const modSlots = (() => {
try {
const parsed = JSON.parse(String(form.get('modSlots') ?? '[]'));
return Array.isArray(parsed) ? parsed.map((row) => ({ tier: Number(row.tier), slots: [...new Set(Array.isArray(row.slots) ? row.slots.map(String) : [])] })) : [];
return normalizeWeaponModSlotPayload(JSON.parse(String(form.get('modSlots') ?? '[]')));
} catch { return []; }
})();
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
@@ -263,13 +327,8 @@ export const actions: Actions = {
if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) {
return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' });
}
for (let tier = nativeTier; tier <= 2; tier++) {
const configured = modSlots.find((row) => row.tier === tier);
const required = tier === 0 ? 4 : 5;
if (!configured || configured.slots.length !== required || configured.slots.some((slot) => !modSlotTypes.includes(slot as typeof modSlotTypes[number]))) {
return fail(400, { weaponError: `Tier ${tier} requires exactly ${required} unique Weapon Mod slots.` });
}
}
const modSlotError = validateWeaponModSlots(nativeTier, modSlots);
if (modSlotError) return fail(400, { weaponError: modSlotError });
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' });
}
@@ -304,21 +363,6 @@ export const actions: Actions = {
if (!augmentEffects.length || new Set(augmentEffects.map((effect) => effect.statId)).size !== augmentEffects.length) {
return fail(400, { weaponError: 'The first tactical augmentation requires one or two unique attribute effects.' });
}
const baseBonusCount = number(form, 'baseBonusCount');
if (!Number.isInteger(baseBonusCount) || baseBonusCount < 1 || baseBonusCount > 12) {
return fail(400, { weaponError: 'Weapons require between one and twelve base attribute bonuses.' });
}
const baseBonuses = [];
for (let order = 1; order <= baseBonusCount; order++) {
const statId = String(form.get(`baseBonusStat${order}`) ?? '');
const value = number(form, `baseBonusValue${order}`);
if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Base attribute bonus ${order} requires an attribute and a positive value.` });
baseBonuses.push({ statId, value, order });
}
if (new Set(baseBonuses.map((effect) => effect.statId)).size !== baseBonuses.length) {
return fail(400, { weaponError: 'Each base attribute bonus must reference a different attribute.' });
}
if (await (await database.prepare('SELECT id FROM weapons WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { weaponError: 'That weapon name already exists.' });
}
@@ -332,7 +376,6 @@ export const actions: Actions = {
}
const statIds = [...new Set([
...augmentEffects.map((effect) => effect.statId),
...baseBonuses.map((effect) => effect.statId),
...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value))
])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
@@ -368,7 +411,6 @@ export const actions: Actions = {
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
@@ -446,12 +488,7 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await (await database.prepare('DELETE FROM weapon_base_attribute_bonuses WHERE weapon_id=?')).run(weaponId);
for (const effect of baseBonuses) {
await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+76 -24
View File
@@ -11,8 +11,15 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import {
excludedTierZeroWeaponModSlot,
expandSharedWeaponModSlots,
sharedWeaponModSlots,
weaponModSlotsComplete
} from '$lib/weapons/mod-slots';
let { data, form } = $props();
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const categories = [...WEAPON_CATEGORIES];
const categoryOptions = categories.map((value) => ({ value, label: value, icon: value.toLowerCase() as GameIconName, iconOnly: true }));
const tierOptions = [0, 1, 2].map((value) => ({ value: String(value), label: `T${value}` }));
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
@@ -24,8 +31,9 @@
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let bonusEditing = $state<any | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
@@ -35,10 +43,24 @@
return { ...blankTier(tier), ...saved };
});
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
const sharedSlots = sharedWeaponModSlots(modSlots);
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
bonusEditing = null;
};
const editBonusProfile = (profile: any) => {
bonusEditing = { category: profile.category, effects: profile.effects.map((effect: any) => ({ ...effect })) };
editing = null;
};
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier);
const modSlotPayload = () => expandSharedWeaponModSlots(editing.sharedModSlots, editing.t0ExcludedSlot);
const t0SlotCount = () => editing.t0ExcludedSlot ? Math.max(0, editing.sharedModSlots.length - 1) : 0;
const weaponState = (weapon: any) => weapon.pending
? 'PENDING'
: Number(weapon.active) !== 1
? 'ARCHIVED'
: weaponModSlotsComplete(Number(weapon.native_tier), weapon.modSlots)
? 'ACTIVE'
: 'CONFIG REQUIRED';
const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute';
const talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent';
const setCategory = (category: string) => {
@@ -74,13 +96,10 @@
const removeAugmentEffect = (index: number) => {
if (editing && index > 0) editing.augmentEffects = editing.augmentEffects.filter((_: any, effectIndex: number) => effectIndex !== index);
};
const addBaseBonus = () => {
if (editing && editing.baseBonuses.length < 12) editing.baseBonuses = [...editing.baseBonuses, { stat_attribute_id: '', value: 0, sort_order: editing.baseBonuses.length + 1 }];
};
const removeBaseBonus = (index: number) => {
if (editing && editing.baseBonuses.length > 1) editing.baseBonuses = editing.baseBonuses.filter((_: any, bonusIndex: number) => bonusIndex !== index);
};
$effect(() => {
if (editing?.t0ExcludedSlot && !editing.sharedModSlots.includes(editing.t0ExcludedSlot)) {
editing.t0ExcludedSlot = '';
}
if (!openedRequestedWeapon && data.requestedWeaponId) {
const requested = data.weapons.find((weapon) => weapon.id === data.requestedWeaponId);
if (requested) edit(requested);
@@ -91,11 +110,42 @@
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null;
}
if (form?.weaponBonusProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
bonusEditing = null;
}
});
</script>
<svelte:head><title>Weapons — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => editing = blank()}>ADD WEAPON <span>+</span></button></div>
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure shared category bonuses, fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => { editing = blank(); bonusEditing = null; }}>ADD WEAPON <span>+</span></button></div>
<details class="gear-augment-registry weapon-bonus-registry">
<summary class="selection-head"><div><span>SHARED WEAPON CATEGORY BONUSES</span><small>INHERITED BY STANDARD AND EXOTIC WEAPONS</small></div><div><b>{data.bonusProfiles.filter((profile) => profile.effects.length === 3).length} / 6 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
<div class="gear-augment-profile-grid weapon-bonus-profile-grid">
{#each data.bonusProfiles as profile}
<button type="button" class:configured={profile.effects.length === 3} onclick={() => editBonusProfile(profile)}>
<header><div><strong>{profile.category}</strong><small>BASE WEAPON TYPE</small></div><span>{profile.effects.length === 3 ? 'CONFIGURED' : 'REQUIRED'}</span></header>
<div>{#each profile.effects as effect}<span><small>0{effect.sort_order}</small><b>{effect.attribute_name}</b><em>{effect.value}{effect.unit === 'flat' ? '' : effect.unit}</em></span>{/each}</div>
</button>
{/each}
</div>
</details>
{#if bonusEditing}
<div class="dashboard-editor weapon-bonus-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CATEGORY BONUSES</p><h3>{bonusEditing.category}</h3></div><button aria-label="Close weapon category bonus editor" onclick={() => bonusEditing = null}>×</button></div>
<PendingForm action="?/saveBonusProfile">
<input type="hidden" name="category" value={bonusEditing.category}/>
<p class="console-note">Every {bonusEditing.category} inherits these bonuses, including exotic weapons in this category.</p>
<div class="weapon-bonus-fields">
{#each bonusEditing.effects as effect, index}
<label class="field">{effect.attribute_name}<input name={`bonusValue${index + 1}`} type="number" min="0.01" step="any" bind:value={effect.value} required/></label>
{/each}
</div>
{#if form?.bonusProfileError}<p class="error">{form.bonusProfileError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => bonusEditing = null}>CANCEL</button><button class="primary">SAVE CATEGORY BONUSES →</button></div>
</PendingForm>
</div>
{/if}
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL WEAPONS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'standard',label:'STANDARD',count:data.summary.standard,description:'Non-exotic weapons'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic weapons'}]}/>
{#snippet weaponEditor()}
@@ -103,7 +153,7 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="modSlots" value={JSON.stringify(editing.modSlots)}/>
<input type="hidden" name="modSlots" value={JSON.stringify(modSlotPayload())}/>
<div class="weapon-identity-grid">
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} onchange={(values) => setCategory(values[0])} required/>
@@ -140,27 +190,29 @@
<label class="field">ACCURACY<input name="accuracy" type="number" min="0" step="any" bind:value={editing.accuracy} required/></label>
<label class="field">STABILITY<input name="stability" type="number" min="0" step="any" bind:value={editing.stability} required/></label>
</div>
<div class="selection-head"><div><span>BASE ATTRIBUTE BONUSES</span><small>FIXED BONUSES PROVIDED BY THIS WEAPON</small></div><button type="button" class="row-action" onclick={addBaseBonus} disabled={editing.baseBonuses.length >= 12}>ADD BONUS +</button></div>
<input type="hidden" name="baseBonusCount" value={editing.baseBonuses.length}/>
<div class="weapon-base-bonuses">
{#each editing.baseBonuses as bonus: any, index}
<div class="base-bonus-row"><strong>{String(index + 1).padStart(2, '0')}</strong><div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`baseBonusStat${index + 1}`} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={bonus.stat_attribute_id}/></div><label class="field">VALUE<input name={`baseBonusValue${index + 1}`} type="number" step="any" bind:value={bonus.value} required/></label><button type="button" aria-label={`Remove base bonus ${index + 1}`} onclick={() => removeBaseBonus(index)} disabled={editing.baseBonuses.length === 1}>×</button></div>
{/each}
</div>
<div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION</small></div><b>3 TIERS REQUIRED</b></div>
<p class="weapon-mod-slot-note"><b>MOD SLOTS OPTIONAL</b><span>T1 and T2 share one slot set. T0 uses that set minus one slot. ISAC marks the weapon Config Required until you can verify both parts.</span></p>
<div class="weapon-tier-grid">
{#each [0, 1, 2] as tier}
{@const slotConfig = modSlotsAt(tier)}
<fieldset class="weapon-tier-card" class:unavailable={tier < Number(editing.native_tier)} disabled={tier < Number(editing.native_tier)}><legend>TIER {tier}</legend>
{#if tier < Number(editing.native_tier)}<p class="tier-unavailable">BELOW NATIVE TIER · NOT AVAILABLE</p>{/if}
<div class="weapon-range-row"><strong><GameIcon name={iconName(editing.damage_type)} decorative/><span>DMG<small>{editing.damage_type.toUpperCase()} DAMAGE</small></span></strong><label>MIN<input name={`tier${tier}damageMin`} type="number" min="0" step="any" value={tierAt(tier).damage_min} oninput={(event) => tierAt(tier).damage_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}damageMax`} type="number" min="0" step="any" value={tierAt(tier).damage_max} oninput={(event) => tierAt(tier).damage_max = event.currentTarget.valueAsNumber} required/></label></div>
<div class="weapon-mod-slot-config"><div class="selection-head"><div><span>WEAPON MOD SLOTS</span><small>{tier === 0 ? 'EXACTLY FOUR SLOTS' : 'EXACTLY FIVE SLOTS'}</small></div><b>{slotConfig.slots.length} / {tier === 0 ? 4 : 5}</b></div><SearchableAssignment items={data.modSlotOptions} subjectLabel="MOD SLOTS" searchPlaceholder="Search slot types…" bind:selected={slotConfig.slots}/></div>
</fieldset>
{/each}
</div>
<section class="weapon-mod-slot-config">
<div class="selection-head"><div><span>{Number(editing.native_tier) === 2 ? 'T2 WEAPON MOD SLOTS' : 'SHARED T1 / T2 WEAPON MOD SLOTS'}</span><small>SELECT UP TO FIVE VERIFIED SLOTS</small></div><b class:config-required={editing.sharedModSlots.length !== 5}>{editing.sharedModSlots.length} / 5</b></div>
<SearchableAssignment items={data.modSlotOptions} presentation="toggle-grid" subjectLabel="SHARED MOD SLOTS" bind:selected={editing.sharedModSlots}/>
{#if Number(editing.native_tier) === 0}
<div class="weapon-t0-exclusion">
<div class="selection-head"><div><span>T0 SLOT NOT PRESENT</span><small>CHOOSE THE ONE SHARED SLOT OMITTED AT T0</small></div><b class:config-required={!editing.t0ExcludedSlot}>{editing.t0ExcludedSlot ? `${t0SlotCount()} / 4` : 'NOT VERIFIED'}</b></div>
<SegmentedChoice legend="T0 EXCLUSION" name="t0ExcludedSlot" options={[{value:'',label:'NOT VERIFIED'}, ...editing.sharedModSlots.map((slot: string) => ({value:slot,label:slot}))]} columns={Math.min(3, editing.sharedModSlots.length + 1)} bind:value={editing.t0ExcludedSlot}/>
</div>
{/if}
</section>
<div class="weapon-talent-pools">
<div><div class="selection-head"><div><span>TALENT 1 · TOP POOL</span><small>GLOBALLY CLASSIFIED FOR FIRST SLOT</small></div><b>{editing.talent1Ids.length}</b></div><SearchableAssignment items={data.talent1Options} fieldName="talent1Ids" bind:selected={editing.talent1Ids}/></div>
<div><div class="selection-head"><div><span>TALENT 2 · BOTTOM POOL</span><small>GLOBALLY CLASSIFIED FOR SECOND SLOT</small></div><b>{editing.talent2Ids.length}</b></div><SearchableAssignment items={data.talent2Options} fieldName="talent2Ids" bind:selected={editing.talent2Ids}/></div>
<div><div class="selection-head"><div><span>TALENT 1 · TOP POOL</span><small>GLOBALLY CLASSIFIED FOR FIRST SLOT</small></div><b>{editing.talent1Ids.length}</b></div><SearchableAssignment items={data.talent1Options} presentation="visual-grid" fieldName="talent1Ids" bind:selected={editing.talent1Ids}/></div>
<div><div class="selection-head"><div><span>TALENT 2 · BOTTOM POOL</span><small>GLOBALLY CLASSIFIED FOR SECOND SLOT</small></div><b>{editing.talent2Ids.length}</b></div><SearchableAssignment items={data.talent2Options} presentation="visual-grid" fieldName="talent2Ids" bind:selected={editing.talent2Ids}/></div>
</div>
<div class="selection-head"><div><span>TACTICAL AUGMENTATION 01</span><small>ONE OR TWO UNIQUE ATTRIBUTE UPGRADES</small></div><button type="button" class="row-action" onclick={addAugmentEffect} disabled={editing.augmentEffects.length >= 2}>ADD EFFECT +</button></div>
<div class="weapon-augment-effects">
@@ -185,7 +237,7 @@
<div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><SortableColumnHeader label="WEAPON" column="name" sort={data.sort}/><SortableColumnHeader label="CLASS" column="class" sort={data.sort}/><SortableColumnHeader label="FIXED ATTRIBUTES" column="fixed" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TIER RANGE" column="tiers" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TALENT POOLS" column="talents" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="AUGMENTS" column="augments" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class:config-required={weaponState(weapon) === 'CONFIG REQUIRED'} class="state-dot">{weaponState(weapon)}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === weapon.id}
<RegistryInlineEditor id={`weapon-editor-${weapon.id}`} label={`Edit ${weapon.name}`}>
{@render weaponEditor()}