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