Improve ISAC libraries, roles, and public profiles
This commit is contained in:
@@ -84,6 +84,23 @@ search-oriented option sets. Preserve native form semantics, keyboard access,
|
||||
focus-visible, selected, disabled, pressed, responsive, and reduced-motion
|
||||
behavior in shared controls.
|
||||
|
||||
## Development server lifecycle
|
||||
|
||||
After changing source code, styles, configuration, dependencies, or routes,
|
||||
restart the development server before handing work back to the project owner.
|
||||
|
||||
1. Inspect running processes and stop only this project's development-server
|
||||
process tree.
|
||||
2. Start one hidden `npm run dev` process from the repository root. Do not leave
|
||||
duplicate project development servers running.
|
||||
3. Confirm that the server accepts an HTTP request before completing the task.
|
||||
4. Leave the verified development server running so the project owner can
|
||||
review the changes.
|
||||
|
||||
Database maintenance may require the server to remain stopped while the
|
||||
embedded database is locked or changing. Restart and verify the server after
|
||||
the maintenance and validation steps finish.
|
||||
|
||||
## Prototype compatibility
|
||||
|
||||
Treat application data and client payloads as prototype formats until the
|
||||
|
||||
@@ -3,12 +3,16 @@ CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UN
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
display_name TEXT NOT NULL,
|
||||
display_name_key TEXT,
|
||||
public_slug TEXT,
|
||||
bio_markdown TEXT NOT NULL DEFAULT '',
|
||||
is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1)),
|
||||
bio_updated_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO user_profiles(user_id,display_name,updated_at) SELECT id,username,created_at FROM users;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key);
|
||||
CREATE TABLE IF NOT EXISTS profile_moderation (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
bio_hidden INTEGER NOT NULL DEFAULT 0 CHECK(bio_hidden IN (0, 1)),
|
||||
@@ -331,6 +335,7 @@ CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCE
|
||||
CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
|
||||
CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id));
|
||||
CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
|
||||
CREATE TABLE IF NOT EXISTS user_roles (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY(user_id, role_id));
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, scope_json TEXT, PRIMARY KEY(role_id, permission_id));
|
||||
|
||||
+64
-26
@@ -449,8 +449,11 @@ direct URL. Do not present authentication as a way around a disabled section.
|
||||
- On small screens, the split frame becomes a single column and account controls
|
||||
move before archival content.
|
||||
- Public display names and private login usernames are distinct. Display names
|
||||
support normalized Unicode and relaxed visible characters; login usernames
|
||||
remain unique, ASCII-only credentials and must never appear on public-facing
|
||||
support normalized Unicode and relaxed visible characters. They are unique
|
||||
under the product's normalized identity key and generate readable,
|
||||
collision-safe public-profile slugs. New accounts receive the next available
|
||||
`Agent N` placeholder until the owner chooses a name. Login usernames remain
|
||||
unique, ASCII-only credentials and must never appear on public-facing
|
||||
community surfaces. Authorized ISAC user operations may show both.
|
||||
|
||||
### Public Agent profiles
|
||||
@@ -458,11 +461,17 @@ direct URL. Do not present authentication as a way around a disabled section.
|
||||
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. 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.
|
||||
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.
|
||||
|
||||
Use `AgentIdentityLink` for author attribution on builds, guides, comments, and
|
||||
other community records. Its compact and default states combine the circular
|
||||
@@ -471,6 +480,11 @@ 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.
|
||||
|
||||
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
|
||||
@@ -663,10 +677,18 @@ error, or danger indicators.
|
||||
- Roles is a separate User Operations registry. Role definitions
|
||||
compose discrete capabilities through a dense permission catalogue with an
|
||||
explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The
|
||||
built-in Admin role is visible but system-protected. Delegated access managers
|
||||
must never be able to grant `system.admin`; both option visibility and
|
||||
server-side validation enforce this boundary. Role mutations are recorded in
|
||||
the audit log with their previous and resulting permission sets.
|
||||
core Admin role is deletion-protected because it provides the system's
|
||||
unrestricted access mechanism. Full administrators may edit every role and
|
||||
delete seeded operational roles; deletion records a persistent tombstone so
|
||||
migrations do not recreate the role. Delegated access managers may edit
|
||||
seeded operational roles but may delete only custom roles. Every deletion
|
||||
requires an exact-name confirmation that states how many agents will
|
||||
immediately lose the role. Keep Delete separated from Edit and use the danger
|
||||
treatment only inside this confirmed path.
|
||||
Delegated access managers must never be able to grant `system.admin`; both
|
||||
option visibility and server-side validation enforce this boundary. Role
|
||||
mutations are recorded in the audit log with their previous and resulting
|
||||
permission and assignment evidence.
|
||||
- Audit History belongs to a separate `SYSTEM OVERSIGHT` navigation domain.
|
||||
Audit records are append-only operational evidence and show the operator,
|
||||
categorical action, target identifier, timestamp, and expandable before/after
|
||||
@@ -1532,10 +1554,12 @@ Public Gear cards use the same rail for compatible Brand Sets and available
|
||||
Talents so players can identify actual options instead of reading a count.
|
||||
Each icon retains its accessible name and hover title, and missing artwork uses
|
||||
the shared placeholder without suppressing the relationship. Relationship
|
||||
icons are buttons: activating one opens a modal quick view using the same card
|
||||
facts as that record's public database category. The overlay closes through
|
||||
its explicit close action, backdrop, or Escape key. Do not render an empty
|
||||
Talent rail on item types that cannot equip Talents.
|
||||
icons are buttons: activating one opens a modal quick view with the shared
|
||||
database card component and the same facts, tier hierarchy, mechanic rows, and
|
||||
actions shown in that record's public registry. The overlay traps keyboard
|
||||
focus, returns focus to its trigger when closed, and closes through its explicit
|
||||
close action, backdrop, or Escape key. Do not render an empty Talent rail on
|
||||
item types that cannot equip Talents.
|
||||
|
||||
### Public game-database registries
|
||||
|
||||
@@ -1740,16 +1764,26 @@ them below a reliably readable width.
|
||||
|
||||
Talent registry cards treat the two tier descriptions as their primary
|
||||
content; never replace them with configured/missing summary facts. Render Tier
|
||||
I and Tier II as distinct structured data blocks, then place optional cooldown
|
||||
metadata below both descriptions. Do not use the serif, quote-like prose
|
||||
treatment for tier effects. Use compact fixed tier labels, sans-serif effect
|
||||
text, and engineered borders. Tier I is the neutral baseline; Tier II uses the
|
||||
orange rail, label, and subtly elevated surface to communicate that it is the
|
||||
improved version. Duration and stack limits may remain compact badges. The
|
||||
top-right header status uses the applicable Backpack or Body Armor icon for
|
||||
gear talents and the SMG icon as the generic Weapon Talent symbol. Weapon
|
||||
Talent headers pair that icon with an explicit `1 · TOP` or `2 · BOTTOM`
|
||||
status badge; never rely on the slot number alone.
|
||||
I and Tier II as distinct structured data blocks, then place mechanics metadata
|
||||
below both descriptions. Use 16px semibold sans-serif text for tier effects so
|
||||
players can scan them before supporting facts. Do not use the serif,
|
||||
quote-like prose treatment. Use compact fixed tier labels and engineered
|
||||
borders. Tier I is the neutral baseline; Tier II uses the orange rail, label,
|
||||
and subtly elevated surface to communicate that it is the improved version.
|
||||
Render duration and stack limits as detail rows with activation and cooldown
|
||||
data. Keep calculation effects out of public Talent cards because the tier
|
||||
descriptions already state the player-facing values. Omit a stack limit of one
|
||||
because a single active instance is the default. The top-right header status
|
||||
uses the applicable Backpack or Body Armor icon for gear talents and the SMG
|
||||
icon as the generic Weapon Talent symbol. Weapon Talent headers pair that icon
|
||||
with an explicit `1 · TOP` or `2 · BOTTOM` status badge; never rely on the slot
|
||||
number alone.
|
||||
|
||||
End each Talent card with a compatibility action. Gear Talent actions navigate
|
||||
to the Gear registry with the Talent filter selected. Weapon Talent actions
|
||||
navigate to the Weapons registry with the Talent Pool filter selected. Keep
|
||||
this action in shared cards and relationship quick views so both contexts use
|
||||
the same destination and label.
|
||||
|
||||
When a public registry needs only one small bounded filter vocabulary, use the
|
||||
compact facet-panel form: one full-width group with all options visible in a
|
||||
@@ -2265,4 +2299,8 @@ such as quality, focus, ability, or Talent category.
|
||||
Operators may rename, recategorize, and add tags to the library record. Replacing
|
||||
the icon from its game-data editor keeps that metadata and refreshes the context
|
||||
tags and file facts. Removing the source icon removes its linked library record.
|
||||
General library uploads retain the `ICON` or `IMAGE` state.
|
||||
Each library card offers `DOWNLOAD`, `EDIT`, and `DELETE` in a connected action
|
||||
rail. `DELETE` opens a focused confirmation that names the asset and states that
|
||||
the change is permanent. For assigned icons, the confirmation names the owning
|
||||
entity type. ISAC clears that source icon assignment before it removes the
|
||||
shared file. General library uploads retain the `ICON` or `IMAGE` state.
|
||||
|
||||
@@ -9,6 +9,20 @@ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Arra
|
||||
const hasExactKeys = (value, keys) =>
|
||||
isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
||||
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
const displayNameKey = (displayName) => String(displayName ?? '').normalize('NFKC').toLocaleLowerCase('en-US');
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const publicProfileSlugBase = (displayName, userId) => {
|
||||
const normalized = String(displayName ?? '')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase('en-US')
|
||||
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64)
|
||||
.replace(/-+$/g, '');
|
||||
const fallback = `agent-${String(userId).replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
||||
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
|
||||
return uuidPattern.test(normalized) ? `agent-${normalized}` : normalized;
|
||||
};
|
||||
const isRoll = (value) =>
|
||||
hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value);
|
||||
const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||
@@ -147,6 +161,14 @@ if (userProfilesTable) {
|
||||
if (!profileColumns.some((column) => column.name === 'bio_updated_at')) {
|
||||
await database.exec('ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT');
|
||||
}
|
||||
if (!profileColumns.some((column) => column.name === 'public_slug')) {
|
||||
console.log('Adding readable public profile slugs.');
|
||||
await database.exec('ALTER TABLE user_profiles ADD COLUMN public_slug TEXT');
|
||||
}
|
||||
if (!profileColumns.some((column) => column.name === 'display_name_key')) {
|
||||
console.log('Adding normalized display-name identity keys.');
|
||||
await database.exec('ALTER TABLE user_profiles ADD COLUMN display_name_key TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
const commentsDefinition = await (await database.prepare(
|
||||
@@ -359,9 +381,63 @@ for (const statement of statements) {
|
||||
if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue;
|
||||
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue;
|
||||
if (statement.startsWith('CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx')) continue;
|
||||
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx')) continue;
|
||||
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx')) continue;
|
||||
await database.exec(statement);
|
||||
}
|
||||
|
||||
const deletedRoleTable = await (await database.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'"
|
||||
)).get();
|
||||
if (deletedRoleTable) {
|
||||
const deletedRoleRows = await (await database.prepare('SELECT role_id FROM deleted_roles')).all();
|
||||
for (const deletedRole of deletedRoleRows) {
|
||||
const roleId = String(deletedRole.role_id);
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(roleId);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId);
|
||||
await (await database.prepare('DELETE FROM roles WHERE id=?')).run(roleId);
|
||||
}
|
||||
}
|
||||
|
||||
const profilesForIdentityMigration = await (await database.prepare(`SELECT user_id,display_name,display_name_key,public_slug
|
||||
FROM user_profiles ORDER BY user_id`)).all();
|
||||
const claimedProfileSlugs = new Set(
|
||||
profilesForIdentityMigration
|
||||
.filter((profile) => profile.public_slug)
|
||||
.map((profile) => String(profile.public_slug))
|
||||
);
|
||||
const claimedDisplayNameKeys = new Set();
|
||||
for (const profile of profilesForIdentityMigration) {
|
||||
const userId = String(profile.user_id);
|
||||
let displayName = String(profile.display_name);
|
||||
let key = displayNameKey(displayName);
|
||||
if (claimedDisplayNameKeys.has(key)) {
|
||||
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
|
||||
const candidate = `${displayName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`;
|
||||
const candidateKey = displayNameKey(candidate);
|
||||
if (!claimedDisplayNameKeys.has(candidateKey)) {
|
||||
displayName = candidate;
|
||||
key = candidateKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
claimedDisplayNameKeys.add(key);
|
||||
let slug = profile.public_slug ? String(profile.public_slug) : '';
|
||||
if (!slug || displayName !== String(profile.display_name)) {
|
||||
const base = publicProfileSlugBase(displayName, userId);
|
||||
slug = claimedProfileSlugs.has(base)
|
||||
? `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`
|
||||
: base;
|
||||
}
|
||||
claimedProfileSlugs.add(slug);
|
||||
await (await database.prepare(`UPDATE user_profiles
|
||||
SET display_name=?,display_name_key=?,public_slug=? WHERE user_id=?`))
|
||||
.run(displayName, key, slug, userId);
|
||||
}
|
||||
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug)');
|
||||
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key)');
|
||||
|
||||
const skillProfileDefinition = await (await database.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'"
|
||||
)).get();
|
||||
|
||||
+59
-24
File diff suppressed because one or more lines are too long
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
agentProfileHref,
|
||||
displayNameKey,
|
||||
newAgentDisplayName,
|
||||
publicProfileSlugBase
|
||||
} from './agents.js';
|
||||
|
||||
describe('display names', () => {
|
||||
it('uses one normalized key for uniqueness checks', () => {
|
||||
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
|
||||
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
|
||||
});
|
||||
|
||||
it('creates readable short-UID placeholders', () => {
|
||||
expect(newAgentDisplayName(47)).toBe('Agent 47');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicProfileSlugBase', () => {
|
||||
it('creates readable normalized slugs', () => {
|
||||
expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000'))
|
||||
.toBe('aewyn-prime');
|
||||
expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000'))
|
||||
.toBe('élite-装備');
|
||||
});
|
||||
|
||||
it('uses a stable fallback when a name has no letters or numbers', () => {
|
||||
expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'))
|
||||
.toBe('agent-12345678');
|
||||
});
|
||||
|
||||
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
|
||||
expect(publicProfileSlugBase(
|
||||
'57a1160e-632f-4695-927f-7df03e20753f',
|
||||
'12345678-0000-4000-8000-000000000000'
|
||||
)).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentProfileHref', () => {
|
||||
it('prefers the public slug and safely encodes it', () => {
|
||||
expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments'))
|
||||
.toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
|
||||
});
|
||||
|
||||
it('falls back to the UUID for unmigrated records', () => {
|
||||
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export type AgentProfileReference = {
|
||||
id: string;
|
||||
publicSlug?: string | null;
|
||||
};
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function displayNameKey(displayName: string): string {
|
||||
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
export function newAgentDisplayName(sequence: number): string {
|
||||
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
|
||||
}
|
||||
|
||||
export function publicProfileSlugBase(displayName: string, userId: string): string {
|
||||
const normalized = displayName
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase('en-US')
|
||||
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64)
|
||||
.replace(/-+$/g, '');
|
||||
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
||||
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
|
||||
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
|
||||
}
|
||||
|
||||
export function agentProfileHref(
|
||||
agent: AgentProfileReference,
|
||||
fragment = ''
|
||||
): string {
|
||||
const identifier = agent.publicSlug || agent.id;
|
||||
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
|
||||
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { agentProfileHref } from '$lib/agents';
|
||||
|
||||
let {
|
||||
agent,
|
||||
size = 'default',
|
||||
@@ -6,7 +8,7 @@
|
||||
showId = false,
|
||||
linked = true
|
||||
}: {
|
||||
agent: { id: string; displayName: string; avatarUrl: string };
|
||||
agent: { id: string; displayName: string; avatarUrl: string; publicSlug?: string | null };
|
||||
size?: 'compact' | 'default' | 'large';
|
||||
meta?: string;
|
||||
showId?: boolean;
|
||||
@@ -15,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
{#if linked}
|
||||
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={`/agents/${encodeURIComponent(agent.id)}`}>
|
||||
<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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<article class="database-item-card">
|
||||
<article class="database-item-card" class:talent-card={item.variant === 'talent'}>
|
||||
<header>
|
||||
<div class="database-item-icon">
|
||||
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
|
||||
@@ -41,8 +41,15 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||
{#if item.variant === 'talent'}
|
||||
<div class="database-talent-tiers">
|
||||
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
||||
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
|
||||
</div>
|
||||
{:else}
|
||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||
{/if}
|
||||
{#if item.afterLines?.length}
|
||||
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import DatabaseItemCard from './DatabaseItemCard.svelte';
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
import DatabaseFacts from './DatabaseFacts.svelte';
|
||||
import type { DatabaseRelatedItem } from '$lib/database/items';
|
||||
|
||||
let {
|
||||
@@ -13,14 +14,45 @@
|
||||
emptyLabel?: string;
|
||||
} = $props();
|
||||
let selected = $state<DatabaseRelatedItem | null>(null);
|
||||
let trigger = $state<HTMLButtonElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let dialog = $state<HTMLDivElement | null>(null);
|
||||
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
|
||||
selected = item;
|
||||
trigger = button;
|
||||
await tick();
|
||||
closeButton?.focus();
|
||||
};
|
||||
const closePreview = async () => {
|
||||
const previousTrigger = trigger;
|
||||
selected = null;
|
||||
trigger = null;
|
||||
await tick();
|
||||
previousTrigger?.focus();
|
||||
};
|
||||
const trapDialogFocus = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || !dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
|
||||
.filter((element) => !element.hasAttribute('hidden'));
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') selected = null; }}/>
|
||||
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && selected) void closePreview(); }}/>
|
||||
|
||||
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
|
||||
{#if items.length}
|
||||
{#each items as item}
|
||||
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} onclick={() => selected = item}>
|
||||
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} aria-haspopup={item.preview ? 'dialog' : undefined} onclick={(event) => void openPreview(item, event.currentTarget)}>
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -30,22 +62,10 @@
|
||||
</div>
|
||||
|
||||
{#if selected?.preview}
|
||||
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) selected = null; }}>
|
||||
<div class="database-preview-dialog" role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`}>
|
||||
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" aria-label="Close details" onclick={() => selected = null}>×</button></header>
|
||||
<article class="database-item-card">
|
||||
<header>
|
||||
<div class="database-item-icon"><ItemIcon src={selected.preview.iconUrl} alt=""/></div>
|
||||
<div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div>
|
||||
</header>
|
||||
{#if selected.preview.badges?.length}<div class="database-item-badges">{#each selected.preview.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
<DatabaseFacts facts={selected.preview.facts} layout={selected.preview.factLayout}/>
|
||||
{#if selected.preview.lines?.length}<div class="database-item-lines">{#each selected.preview.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
|
||||
{#if selected.preview.description}<p class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.description}</p>{/if}
|
||||
{#if selected.preview.secondaryDescription}<p class="secondary-description" class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.secondaryDescription}</p>{/if}
|
||||
{#if selected.preview.afterLines?.length}<div class="database-item-lines after-description">{#each selected.preview.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
|
||||
{#if selected.preview.actions?.length}<footer class="database-item-actions">{#each selected.preview.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>{/if}
|
||||
</article>
|
||||
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
|
||||
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
|
||||
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
|
||||
<DatabaseItemCard item={selected.preview}/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
roles,
|
||||
label = 'Assigned site roles',
|
||||
compact = false
|
||||
}: {
|
||||
roles: string[];
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
|
||||
const visibleRoles = $derived(
|
||||
[...new Set(roles.map((role) => role.trim()).filter(Boolean))]
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if visibleRoles.length}
|
||||
<ul class="role-badge-list" class:compact aria-label={label}>
|
||||
{#each visibleRoles as role}
|
||||
<li><span aria-hidden="true"></span>{role}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.role-badge-list{min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin:0;padding:0;list-style:none}
|
||||
.role-badge-list li{min-width:0;min-height:27px;display:flex;align-items:center;gap:7px;padding:4px 8px;border:1px solid var(--shd-border);background:var(--shd-surface-raised);color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;line-height:1.25;letter-spacing:.65px;text-transform:uppercase;overflow-wrap:anywhere}
|
||||
.role-badge-list li span{flex:none;width:5px;height:5px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 35%,transparent);transform:rotate(45deg)}
|
||||
.role-badge-list.compact{gap:3px}.role-badge-list.compact li{min-height:23px;padding:3px 5px;gap:5px;letter-spacing:.55px}.role-badge-list.compact li span{width:4px;height:4px}
|
||||
@media(max-width:600px){.role-badge-list:not(.compact) li{min-height:30px}}
|
||||
</style>
|
||||
@@ -15,8 +15,9 @@ export type DatabaseItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kicker: string;
|
||||
variant?: 'talent';
|
||||
iconUrl?: string;
|
||||
gameIcon?: string;
|
||||
gameIcon?: GameIconName;
|
||||
headerBadge?: string;
|
||||
headerMetaIcon?: GameIconName;
|
||||
badges?: DatabaseBadge[];
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createAvailableDisplayName,
|
||||
displayNameIsAvailable
|
||||
} from './agent-profiles.js';
|
||||
import { displayNameKey } from '../agents.js';
|
||||
|
||||
describe('public profile identity allocation', () => {
|
||||
it('allocates the next available Agent number', async () => {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
display_name_key TEXT UNIQUE,
|
||||
public_slug TEXT
|
||||
)`);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
|
||||
.run('existing', 'Agent 47', displayNameKey('Agent 47'));
|
||||
|
||||
expect(await createAvailableDisplayName(database, 'Agent', 'new-user', true)).toBe('Agent 48');
|
||||
});
|
||||
|
||||
it('compares display names through the normalized Unicode key', async () => {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
display_name_key TEXT UNIQUE,
|
||||
public_slug TEXT
|
||||
)`);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
|
||||
.run('existing', 'Élite', displayNameKey('Élite'));
|
||||
|
||||
expect(await displayNameIsAvailable(database, 'E\u0301lite')).toBe(false);
|
||||
expect(await displayNameIsAvailable(database, 'Élite', 'existing')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import {
|
||||
displayNameKey,
|
||||
newAgentDisplayName,
|
||||
publicProfileSlugBase
|
||||
} from '$lib/agents';
|
||||
|
||||
export async function displayNameIsAvailable(
|
||||
database: Database,
|
||||
displayName: string,
|
||||
exceptUserId = ''
|
||||
): Promise<boolean> {
|
||||
const row = await (await database.prepare(
|
||||
'SELECT user_id FROM user_profiles WHERE display_name_key=? AND user_id<>?'
|
||||
)).get(displayNameKey(displayName), exceptUserId);
|
||||
return !row;
|
||||
}
|
||||
|
||||
export async function createAvailableDisplayName(
|
||||
database: Database,
|
||||
preferredName: string,
|
||||
userId: string,
|
||||
placeholder = false
|
||||
): Promise<string> {
|
||||
if (!placeholder && await displayNameIsAvailable(database, preferredName)) return preferredName;
|
||||
if (placeholder) {
|
||||
const names = await (await database.prepare(
|
||||
"SELECT display_name FROM user_profiles WHERE display_name LIKE 'Agent %'"
|
||||
)).all();
|
||||
let sequence = names.reduce((highest, row) => {
|
||||
const match = /^Agent ([1-9]\d*)$/.exec(String(row.display_name));
|
||||
return match ? Math.max(highest, Number(match[1])) : highest;
|
||||
}, 0) + 1;
|
||||
while (!(await displayNameIsAvailable(database, newAgentDisplayName(sequence)))) sequence += 1;
|
||||
return newAgentDisplayName(sequence);
|
||||
}
|
||||
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
|
||||
const candidate = `${preferredName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`;
|
||||
if (await displayNameIsAvailable(database, candidate)) return candidate;
|
||||
}
|
||||
throw new Error('Unable to allocate a unique display name.');
|
||||
}
|
||||
|
||||
export async function createPublicProfileSlug(
|
||||
database: Database,
|
||||
displayName: string,
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
const base = publicProfileSlugBase(displayName, userId);
|
||||
const existing = await (await database.prepare(
|
||||
'SELECT user_id FROM user_profiles WHERE public_slug=?'
|
||||
)).get(base);
|
||||
if (!existing || String(existing.user_id) === userId) return base;
|
||||
return `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
||||
}
|
||||
|
||||
export async function insertUserProfile(
|
||||
database: Database,
|
||||
userId: string,
|
||||
preferredName: string,
|
||||
updatedAt: string,
|
||||
options: { placeholder?: boolean; resolveCollision?: boolean } = {}
|
||||
): Promise<{ displayName: string; publicSlug: string }> {
|
||||
const displayName = options.placeholder || options.resolveCollision
|
||||
? await createAvailableDisplayName(database, preferredName, userId, Boolean(options.placeholder))
|
||||
: preferredName;
|
||||
const publicSlug = await createPublicProfileSlug(database, displayName, userId);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)`))
|
||||
.run(userId, displayName, displayNameKey(displayName), publicSlug, updatedAt);
|
||||
return { displayName, publicSlug };
|
||||
}
|
||||
|
||||
export async function resolveAgentProfile(
|
||||
database: Database,
|
||||
identifier: string
|
||||
): Promise<{ id: string; publicSlug: string; displayName: string } | null> {
|
||||
const row = await (await database.prepare(`SELECT users.id,
|
||||
COALESCE(profile.public_slug,users.id) AS public_slug,
|
||||
COALESCE(profile.display_name,users.username) AS display_name
|
||||
FROM users LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
WHERE users.id=? OR profile.public_slug=?
|
||||
ORDER BY CASE WHEN profile.public_slug=? THEN 0 ELSE 1 END
|
||||
LIMIT 1`)).get(identifier, identifier, identifier);
|
||||
return row ? {
|
||||
id: String(row.id),
|
||||
publicSlug: String(row.public_slug),
|
||||
displayName: String(row.display_name)
|
||||
} : null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||
import { displayNameKey } from '$lib/agents';
|
||||
|
||||
export async function softDeleteAccount(
|
||||
database: Database,
|
||||
@@ -18,6 +19,7 @@ export async function softDeleteAccount(
|
||||
if (!user || user.deleted_at) return false;
|
||||
const now = new Date().toISOString();
|
||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
||||
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
||||
|
||||
await (await database.prepare(
|
||||
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
|
||||
@@ -26,15 +28,16 @@ export async function softDeleteAccount(
|
||||
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
|
||||
)).run(deletedUsername, userId);
|
||||
await (await database.prepare(
|
||||
`INSERT INTO user_profiles(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,0,?,?)
|
||||
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
display_name_key=excluded.display_name_key,
|
||||
bio_markdown='',
|
||||
is_public=0,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`
|
||||
)).run(userId, 'Deleted Agent', '', now, now);
|
||||
)).run(userId, deletedDisplayName, displayNameKey(deletedDisplayName), '', now, now);
|
||||
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
|
||||
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
|
||||
|
||||
@@ -12,6 +12,7 @@ export type AuthUser = {
|
||||
username: string;
|
||||
loginUsername: string | null;
|
||||
displayName: string;
|
||||
publicSlug: string | null;
|
||||
role: string;
|
||||
discordLinked: boolean;
|
||||
avatarUrl: string;
|
||||
@@ -111,6 +112,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare(`
|
||||
SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
user_profiles.public_slug,
|
||||
users.role, users.discord_id, user_avatars.source AS avatar_source,
|
||||
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
|
||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
@@ -147,6 +149,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
||||
username: String(user.username),
|
||||
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
|
||||
displayName: String(user.display_name),
|
||||
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
|
||||
role: String(user.role),
|
||||
discordLinked: typeof user.discord_id === 'string',
|
||||
avatarUrl: avatarUrl(user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('node:fs/promises')>(),
|
||||
rm: rmMock
|
||||
}));
|
||||
|
||||
import { GAME_ICON_TYPES } from './game-data-icons';
|
||||
import { ManagedImageDeletionError, deleteManagedImage } from './managed-images';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
class FakeDatabase {
|
||||
images = new Map<string, Row>();
|
||||
icons = new Map<string, Row>();
|
||||
executed: string[] = [];
|
||||
|
||||
prepare(sql: string) {
|
||||
return Promise.resolve({
|
||||
get: async (...args: unknown[]) => {
|
||||
if (sql.startsWith('SELECT * FROM managed_images')) return this.images.get(String(args[0]));
|
||||
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
||||
return this.icons.get(`${String(args[0])}:${String(args[1])}`);
|
||||
}
|
||||
throw new Error(`Unexpected get: ${sql}`);
|
||||
},
|
||||
run: async (...args: unknown[]) => {
|
||||
if (sql.startsWith('DELETE FROM game_data_icons')) {
|
||||
const key = `${String(args[0])}:${String(args[1])}`;
|
||||
const row = this.icons.get(key);
|
||||
if (row?.storage_key === args[2]) this.icons.delete(key);
|
||||
return;
|
||||
}
|
||||
if (sql.startsWith('DELETE FROM managed_images')) {
|
||||
const row = this.images.get(String(args[0]));
|
||||
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unexpected run: ${sql}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async exec(sql: string) {
|
||||
this.executed.push(sql);
|
||||
}
|
||||
}
|
||||
|
||||
const imageId = '11111111-1111-4111-8111-111111111111';
|
||||
const sourceId = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
describe('deleteManagedImage', () => {
|
||||
beforeEach(() => rmMock.mockReset().mockResolvedValue(undefined));
|
||||
|
||||
it.each(GAME_ICON_TYPES)('detaches and deletes an assigned %s image', async (sourceType) => {
|
||||
const database = new FakeDatabase();
|
||||
const storageKey = `${sourceType}-${sourceId}.webp`;
|
||||
database.images.set(imageId, {
|
||||
id: imageId, storage_key: storageKey,
|
||||
source_entity_type: sourceType, source_entity_id: sourceId
|
||||
});
|
||||
database.icons.set(`${sourceType}:${sourceId}`, {
|
||||
entity_type: sourceType, entity_id: sourceId, storage_key: storageKey
|
||||
});
|
||||
|
||||
const deleted = await deleteManagedImage(database as never, imageId);
|
||||
|
||||
expect(deleted?.sourceIcon).toMatchObject({ entity_type: sourceType, entity_id: sourceId });
|
||||
expect(database.images.has(imageId)).toBe(false);
|
||||
expect(database.icons.has(`${sourceType}:${sourceId}`)).toBe(false);
|
||||
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
|
||||
expect(rmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('deletes a standalone library image', async () => {
|
||||
const database = new FakeDatabase();
|
||||
database.images.set(imageId, {
|
||||
id: imageId, storage_key: `library-${imageId}.webp`,
|
||||
source_entity_type: null, source_entity_id: null
|
||||
});
|
||||
|
||||
const deleted = await deleteManagedImage(database as never, imageId);
|
||||
|
||||
expect(deleted?.sourceIcon).toBeNull();
|
||||
expect(database.images.has(imageId)).toBe(false);
|
||||
expect(database.icons.size).toBe(0);
|
||||
expect(rmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('deletes an orphaned linked image without touching another assignment', async () => {
|
||||
const database = new FakeDatabase();
|
||||
database.images.set(imageId, {
|
||||
id: imageId, storage_key: `talent-${sourceId}.webp`,
|
||||
source_entity_type: 'talent', source_entity_id: sourceId
|
||||
});
|
||||
|
||||
const deleted = await deleteManagedImage(database as never, imageId);
|
||||
|
||||
expect(deleted?.sourceIcon).toBeNull();
|
||||
expect(database.images.has(imageId)).toBe(false);
|
||||
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
|
||||
});
|
||||
|
||||
it('fails closed when the library and source rows reference different files', async () => {
|
||||
const database = new FakeDatabase();
|
||||
database.images.set(imageId, {
|
||||
id: imageId, storage_key: `talent-${sourceId}.webp`,
|
||||
source_entity_type: 'talent', source_entity_id: sourceId
|
||||
});
|
||||
database.icons.set(`talent:${sourceId}`, {
|
||||
entity_type: 'talent', entity_id: sourceId,
|
||||
storage_key: 'talent-33333333-3333-4333-8333-333333333333.webp'
|
||||
});
|
||||
|
||||
await expect(deleteManagedImage(database as never, imageId)).rejects.toThrow(ManagedImageDeletionError);
|
||||
expect(database.images.has(imageId)).toBe(true);
|
||||
expect(database.icons.has(`talent:${sourceId}`)).toBe(true);
|
||||
expect(database.executed).toEqual([]);
|
||||
expect(rmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source_entity_type: 'talent', source_entity_id: null },
|
||||
{ source_entity_type: null, source_entity_id: sourceId },
|
||||
{ source_entity_type: 'unknown', source_entity_id: sourceId }
|
||||
])('rejects an invalid source relationship: $source_entity_type / $source_entity_id', async (source) => {
|
||||
const database = new FakeDatabase();
|
||||
database.images.set(imageId, {
|
||||
id: imageId, storage_key: `library-${imageId}.webp`, ...source
|
||||
});
|
||||
|
||||
await expect(deleteManagedImage(database as never, imageId)).rejects.toThrow(ManagedImageDeletionError);
|
||||
expect(database.images.has(imageId)).toBe(true);
|
||||
expect(database.executed).toEqual([]);
|
||||
expect(rmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
|
||||
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
|
||||
|
||||
export const MANAGED_IMAGE_CATEGORIES = [
|
||||
@@ -14,6 +15,7 @@ const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
||||
|
||||
export class ManagedImageValidationError extends Error {}
|
||||
export class ManagedImageDeletionError extends Error {}
|
||||
|
||||
export function managedImageStorageDirectory() {
|
||||
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
|
||||
@@ -102,6 +104,44 @@ export async function saveManagedImageFile(id: string, output: Buffer) {
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function deleteManagedImage(database: Database, id: string) {
|
||||
const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
|
||||
if (!image) return null;
|
||||
if (typeof image.storage_key !== 'string') throw new ManagedImageDeletionError('Managed image is missing its storage key.');
|
||||
|
||||
const sourceType = typeof image.source_entity_type === 'string' ? image.source_entity_type : null;
|
||||
const sourceId = typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
|
||||
if (Boolean(sourceType) !== Boolean(sourceId)) {
|
||||
throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.');
|
||||
}
|
||||
if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)) {
|
||||
throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.');
|
||||
}
|
||||
|
||||
const sourceIcon = sourceType && sourceId
|
||||
? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null
|
||||
: null;
|
||||
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
|
||||
throw new ManagedImageDeletionError('The library image and its source assignment reference different files. Resolve the assignment before deleting.');
|
||||
}
|
||||
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (sourceIcon && sourceType && sourceId) {
|
||||
await (await database.prepare(
|
||||
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?'
|
||||
)).run(sourceType, sourceId, image.storage_key);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM managed_images WHERE id=? AND storage_key=?')).run(id, image.storage_key);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
await rm(storagePath(image.storage_key), { force: true });
|
||||
return { image, sourceIcon };
|
||||
}
|
||||
|
||||
export async function readManagedImage(database: Database, id: string) {
|
||||
if (!ID_PATTERN.test(id)) return null;
|
||||
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from './notification-sort';
|
||||
|
||||
describe('notification sorting', () => {
|
||||
it('defaults unknown and missing filters to unread oldest-first', () => {
|
||||
expect(parseNotificationState('unexpected')).toBe('unread');
|
||||
expect(parseNotificationState(null)).toBe('unread');
|
||||
expect(parseNotificationSort('unexpected')).toBe('oldest');
|
||||
expect(parseNotificationSort(null)).toBe('oldest');
|
||||
});
|
||||
|
||||
it('maps newest and oldest to chronological timestamp directions', () => {
|
||||
expect(notificationOrderBy('newest')).toContain('unixepoch(n.created_at) DESC');
|
||||
expect(notificationOrderBy('oldest')).toContain('unixepoch(n.created_at) ASC');
|
||||
});
|
||||
|
||||
it('keeps unread messages first and sorts each state newest first', () => {
|
||||
const expression = notificationOrderBy('unread');
|
||||
expect(expression).toContain('CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END');
|
||||
expect(expression).toContain('unixepoch(n.created_at) DESC');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export const NOTIFICATION_SORTS = ['newest', 'oldest', 'unread'] as const;
|
||||
export const NOTIFICATION_STATES = ['all', 'unread', 'read'] as const;
|
||||
|
||||
export type NotificationSort = (typeof NOTIFICATION_SORTS)[number];
|
||||
export type NotificationState = (typeof NOTIFICATION_STATES)[number];
|
||||
|
||||
export function parseNotificationSort(value: unknown): NotificationSort {
|
||||
const requested = String(value ?? '');
|
||||
return NOTIFICATION_SORTS.includes(requested as NotificationSort)
|
||||
? requested as NotificationSort
|
||||
: 'oldest';
|
||||
}
|
||||
|
||||
export function parseNotificationState(value: unknown): NotificationState {
|
||||
const requested = String(value ?? '');
|
||||
return NOTIFICATION_STATES.includes(requested as NotificationState)
|
||||
? requested as NotificationState
|
||||
: 'unread';
|
||||
}
|
||||
|
||||
export function notificationOrderBy(sort: NotificationSort) {
|
||||
return {
|
||||
newest: 'unixepoch(n.created_at) DESC, n.created_at DESC, n.id DESC',
|
||||
oldest: 'unixepoch(n.created_at) ASC, n.created_at ASC, n.id ASC',
|
||||
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, unixepoch(n.created_at) DESC, n.created_at DESC, n.id DESC'
|
||||
}[sort];
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
moderateCommunityText,
|
||||
recordCommunityModerationScan
|
||||
} from '$lib/server/community-moderation';
|
||||
import { agentProfileHref, displayNameKey } from '$lib/agents';
|
||||
import {
|
||||
createPublicProfileSlug,
|
||||
displayNameIsAvailable
|
||||
} from '$lib/server/agent-profiles';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
@@ -39,7 +44,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(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,
|
||||
(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
|
||||
@@ -112,7 +117,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
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: `/agents/${encodeURIComponent(user.id)}`
|
||||
href: agentProfileHref({
|
||||
id: user.id,
|
||||
publicSlug: typeof profile?.public_slug === 'string' ? profile.public_slug : null
|
||||
})
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -334,9 +342,18 @@ export const actions: Actions = {
|
||||
return fail(400, { displayNameError: 'Use 2–40 visible characters on one line.' });
|
||||
}
|
||||
const now = new Date().toISOString(), database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`))
|
||||
.run(user.id, displayName, now);
|
||||
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
|
||||
return fail(409, { displayNameError: 'That display name is already in use.' });
|
||||
}
|
||||
const publicSlug = await createPublicProfileSlug(database, displayName, user.id);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
display_name_key=excluded.display_name_key,
|
||||
public_slug=excluded.public_slug,
|
||||
updated_at=excluded.updated_at`))
|
||||
.run(user.id, displayName, displayNameKey(displayName), publicSlug, now);
|
||||
return { displayNameChanged: true };
|
||||
},
|
||||
changePassword: async ({ request, cookies }) => {
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<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={`/agents/${encodeURIComponent(user.id)}`}>VIEW PUBLIC PROFILE →</a></div></div></div>
|
||||
<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}
|
||||
@@ -168,7 +168,7 @@
|
||||
{/if}
|
||||
<hr/>
|
||||
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
|
||||
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
|
||||
<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}
|
||||
|
||||
@@ -5,16 +5,15 @@ import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
|
||||
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from '$lib/server/notification-sort';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const requestedState = String(url.searchParams.get('state') ?? 'all');
|
||||
const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
|
||||
const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
|
||||
const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
|
||||
const state = parseNotificationState(url.searchParams.get('state'));
|
||||
const sort = parseNotificationSort(url.searchParams.get('sort'));
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
|
||||
const args: unknown[] = [user.id];
|
||||
@@ -27,11 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
args.push(pattern, pattern, pattern);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const orderBy = {
|
||||
newest: 'n.created_at DESC, n.id DESC',
|
||||
oldest: 'n.created_at ASC, n.id ASC',
|
||||
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, n.created_at DESC, n.id DESC'
|
||||
}[sort];
|
||||
const orderBy = notificationOrderBy(sort);
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
createUserContentReport,
|
||||
recordCommunityModerationScan
|
||||
} from '$lib/server/community-moderation';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { queueCurrentAvatarReview } from '$lib/server/avatars';
|
||||
import { agentProfileHref } from '$lib/agents';
|
||||
import { resolveAgentProfile } from '$lib/server/agent-profiles';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const COMMENT_MAX_CHARACTERS = 2_000;
|
||||
@@ -22,17 +24,25 @@ 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) => {
|
||||
if (targetType === 'profile') return `/agents/${encodeURIComponent(targetId)}#profile-comments`;
|
||||
const commentHref = (targetType: string, slug: unknown, targetId: string, targetPublicSlug: unknown) => {
|
||||
if (targetType === 'profile') {
|
||||
return agentProfileHref({
|
||||
id: targetId,
|
||||
publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
|
||||
}, 'profile-comments');
|
||||
}
|
||||
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const database = await getDatabase();
|
||||
const viewer = await getSessionUser(cookies);
|
||||
const resolved = await resolveAgentProfile(database, params.id);
|
||||
if (!resolved) error(404, 'Agent profile not found');
|
||||
const agentId = resolved.id;
|
||||
const agent = await (await database.prepare(`SELECT users.id,
|
||||
COALESCE(profile.display_name,users.username) AS display_name,
|
||||
profile.bio_markdown,profile.is_public,profile.bio_updated_at,
|
||||
profile.public_slug,profile.bio_markdown,profile.is_public,profile.bio_updated_at,
|
||||
COALESCE(moderation.bio_hidden,0) AS bio_hidden,
|
||||
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
|
||||
@@ -42,26 +52,34 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
||||
LEFT JOIN user_status status ON status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||||
WHERE users.id=?`)).get(params.id);
|
||||
const isSelf = viewer?.id === params.id;
|
||||
WHERE users.id=?`)).get(agentId);
|
||||
const isSelf = viewer?.id === agentId;
|
||||
if (
|
||||
unavailable(agent)
|
||||
|| (!Boolean(agent?.is_public) && !isSelf)
|
||||
) error(404, 'Agent profile not found');
|
||||
if (params.id !== String(agent.public_slug)) {
|
||||
redirect(308, agentProfileHref({ id: agentId, publicSlug: String(agent.public_slug) }));
|
||||
}
|
||||
|
||||
const [buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
|
||||
const [roleRows, buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
|
||||
await Promise.all([
|
||||
(await database.prepare(`SELECT roles.name FROM user_roles
|
||||
JOIN roles ON roles.id=user_roles.role_id
|
||||
WHERE user_roles.user_id=?
|
||||
ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(agentId),
|
||||
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
|
||||
FROM builds WHERE owner_id=? AND visibility='public'
|
||||
AND (guide_body IS NULL OR length(trim(guide_body))=0)
|
||||
ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
|
||||
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
|
||||
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
|
||||
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(params.id),
|
||||
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
|
||||
(await database.prepare(`SELECT comments.id,comments.body_markdown,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
|
||||
COALESCE(target_profile.display_name,target_user.username) AS target_display_name,
|
||||
target_profile.public_slug AS target_public_slug
|
||||
FROM comments
|
||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
|
||||
@@ -75,9 +93,10 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
|
||||
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
|
||||
)
|
||||
ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id, new Date().toISOString()),
|
||||
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,
|
||||
profile.public_slug,
|
||||
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
|
||||
FROM comment_threads thread
|
||||
JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
|
||||
@@ -85,7 +104,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
||||
WHERE thread.target_type='profile' AND thread.target_id=?
|
||||
ORDER BY comments.created_at DESC LIMIT 100`)).all(params.id),
|
||||
ORDER BY comments.created_at DESC LIMIT 100`)).all(agentId),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
|
||||
@@ -98,28 +117,30 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
|
||||
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
|
||||
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
|
||||
)`)).get(params.id, new Date().toISOString()),
|
||||
)`)).get(agentId, new Date().toISOString()),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
|
||||
.get(params.id),
|
||||
.get(agentId),
|
||||
(await database.prepare(`SELECT
|
||||
SUM(CASE WHEN guide_body IS NULL OR length(trim(guide_body))=0 THEN 1 ELSE 0 END) AS builds,
|
||||
SUM(CASE WHEN guide_body IS NOT NULL AND length(trim(guide_body))>0 THEN 1 ELSE 0 END) AS guides
|
||||
FROM builds WHERE owner_id=? AND visibility='public'`)).get(params.id)
|
||||
FROM builds WHERE owner_id=? AND visibility='public'`)).get(agentId)
|
||||
]);
|
||||
|
||||
return {
|
||||
agent: {
|
||||
id: String(agent.id),
|
||||
displayName: String(agent.display_name),
|
||||
publicSlug: String(agent.public_slug),
|
||||
avatarUrl: avatarUrl(
|
||||
agent.avatar_source,
|
||||
agent.avatar_value,
|
||||
String(agent.id),
|
||||
agent.avatar_updated_at
|
||||
),
|
||||
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown)
|
||||
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown),
|
||||
roles: roleRows.map((role) => String(role.name))
|
||||
},
|
||||
counts: {
|
||||
builds: Number(contentCounts?.builds ?? 0),
|
||||
@@ -152,7 +173,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
targetTitle: targetType === 'profile'
|
||||
? `${String(comment.target_display_name ?? 'Agent')} profile`
|
||||
: String(comment.build_title ?? 'Community content'),
|
||||
targetHref: commentHref(targetType, comment.slug, targetId)
|
||||
targetHref: commentHref(targetType, comment.slug, targetId, comment.target_public_slug)
|
||||
};
|
||||
}),
|
||||
profileComments: profileCommentRows.map((comment) => ({
|
||||
@@ -162,6 +183,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
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,
|
||||
@@ -195,6 +217,9 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const owner = await resolveAgentProfile(database, params.id);
|
||||
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
|
||||
const agentId = owner.id;
|
||||
const moderationPolicy = await getCommunityModerationPolicy();
|
||||
const now = new Date();
|
||||
const rows = await (await database.prepare(`SELECT users.id,
|
||||
@@ -204,9 +229,9 @@ export const actions: Actions = {
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
LEFT JOIN user_status status ON status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||||
WHERE users.id IN (?,?)`)).all(commenter.id, params.id);
|
||||
WHERE users.id IN (?,?)`)).all(commenter.id, agentId);
|
||||
const commenterState = rows.find((row) => row.id === commenter.id);
|
||||
const ownerState = rows.find((row) => row.id === params.id);
|
||||
const ownerState = rows.find((row) => row.id === agentId);
|
||||
if (unavailable(commenterState)) {
|
||||
return fail(403, { profileCommentError: 'This account cannot post comments.' });
|
||||
}
|
||||
@@ -218,7 +243,7 @@ export const actions: Actions = {
|
||||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
|
||||
AND thread.target_type='profile' AND thread.target_id=?
|
||||
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
|
||||
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, agentId);
|
||||
if (typeof recent?.created_at === 'string') {
|
||||
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
|
||||
if (elapsed < RAPID_COMMENT_SECONDS) {
|
||||
@@ -239,10 +264,10 @@ export const actions: Actions = {
|
||||
const proposedThreadId = randomUUID();
|
||||
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
|
||||
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
|
||||
.run(proposedThreadId, params.id, createdAt);
|
||||
.run(proposedThreadId, agentId, createdAt);
|
||||
const thread = await (await database.prepare(
|
||||
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
|
||||
)).get(params.id);
|
||||
)).get(agentId);
|
||||
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
|
||||
const commentId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO comments
|
||||
@@ -255,17 +280,18 @@ export const actions: Actions = {
|
||||
authorId: commenter.id,
|
||||
result: moderation
|
||||
});
|
||||
if (commentStatus === 'visible' && commenter.id !== params.id) {
|
||||
if (commentStatus === 'visible' && commenter.id !== agentId) {
|
||||
const profileLink = agentProfileHref(owner, 'profile-comments');
|
||||
await createNotification({
|
||||
userId: params.id,
|
||||
userId: agentId,
|
||||
category: 'comments',
|
||||
source: 'agent',
|
||||
title: 'New profile comment',
|
||||
body: `${commenter.displayName} posted a comment on your Agent profile.`,
|
||||
link: `/agents/${encodeURIComponent(params.id)}#profile-comments`,
|
||||
link: profileLink,
|
||||
actions: [{
|
||||
label: 'VIEW COMMENT',
|
||||
href: `/agents/${encodeURIComponent(params.id)}#profile-comments`
|
||||
href: profileLink
|
||||
}]
|
||||
});
|
||||
}
|
||||
@@ -283,10 +309,12 @@ export const actions: Actions = {
|
||||
const commentId = String((await request.formData()).get('commentId') ?? '').trim();
|
||||
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
|
||||
const database = await getDatabase();
|
||||
const owner = await resolveAgentProfile(database, params.id);
|
||||
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
|
||||
const comment = await (await database.prepare(`SELECT comments.id,comments.author_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, params.id);
|
||||
AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, owner.id);
|
||||
if (!comment) return fail(404, { profileCommentError: 'That comment is no longer available.' });
|
||||
if (comment.author_id !== viewer.id) {
|
||||
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
|
||||
@@ -326,22 +354,25 @@ export const actions: Actions = {
|
||||
return failure(400, 'Report details may contain up to 500 visible characters.');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const owner = await resolveAgentProfile(database, params.id);
|
||||
if (!owner) return failure(404, 'Agent profile not found.');
|
||||
const agentId = owner.id;
|
||||
if (contentType === 'comment') {
|
||||
const comment = await (await database.prepare(`SELECT comments.author_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(contentId, params.id);
|
||||
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, agentId);
|
||||
if (!comment) return failure(404, 'That comment is no longer available.');
|
||||
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
|
||||
} else {
|
||||
if (contentId !== params.id) return failure(400, 'That profile content does not match this Agent.');
|
||||
if (reporter.id === params.id) return failure(400, 'You cannot report your own profile content.');
|
||||
if (contentId !== agentId) return failure(400, 'That profile content does not match this Agent.');
|
||||
if (reporter.id === agentId) return failure(400, 'You cannot report your own profile content.');
|
||||
const profile = await (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,
|
||||
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
|
||||
FROM user_profiles profile
|
||||
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
||||
LEFT JOIN user_avatars avatar ON avatar.user_id=profile.user_id
|
||||
WHERE profile.user_id=?`)).get(params.id);
|
||||
WHERE profile.user_id=?`)).get(agentId);
|
||||
if (!profile?.is_public) return failure(404, 'That profile is no longer available.');
|
||||
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
|
||||
return failure(404, 'That biography is no longer available.');
|
||||
@@ -359,7 +390,7 @@ export const actions: Actions = {
|
||||
});
|
||||
if (!report) return failure(409, 'You already have a pending report for this content.');
|
||||
if (contentType === 'avatar') {
|
||||
await queueCurrentAvatarReview(database, params.id, { origin: 'user_report' });
|
||||
await queueCurrentAvatarReview(database, agentId, { origin: 'user_report' });
|
||||
}
|
||||
return {
|
||||
userReportSaved: true,
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
|
||||
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
|
||||
|
||||
type PublicAgent = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
publicSlug: string;
|
||||
avatarUrl: string;
|
||||
bioHtml: string;
|
||||
roles: string[];
|
||||
};
|
||||
type PublicContent = {
|
||||
id: string;
|
||||
@@ -32,7 +35,7 @@
|
||||
id: string;
|
||||
bodyHtml: string;
|
||||
createdAt: string;
|
||||
author: { id: string; displayName: string; avatarUrl: string };
|
||||
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string };
|
||||
canDelete: boolean;
|
||||
canReport: boolean;
|
||||
};
|
||||
@@ -76,7 +79,10 @@
|
||||
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
|
||||
|
||||
<header class="agent-command">
|
||||
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
|
||||
<div class="agent-command-identity">
|
||||
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
|
||||
<RoleBadgeList 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}
|
||||
@@ -221,7 +227,7 @@
|
||||
|
||||
<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 :global(.agent-identity){flex:1}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
|
||||
.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}
|
||||
.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}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
||||
import { getSiteAccessSettings } from '$lib/server/site-access';
|
||||
import { insertUserProfile } from '$lib/server/agent-profiles';
|
||||
|
||||
const STATE_COOKIE = 'shd_discord_oauth_state';
|
||||
|
||||
@@ -123,8 +124,13 @@ export const GET: RequestHandler = async ({ cookies, url, fetch, request, getCli
|
||||
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
await insertUser.run(id, username, discordUser.id, 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, discordUser.global_name || discordUser.username, now);
|
||||
await insertUserProfile(
|
||||
database,
|
||||
id,
|
||||
discordUser.global_name || discordUser.username,
|
||||
now,
|
||||
{ resolveCollision: true }
|
||||
);
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
user = { id };
|
||||
|
||||
@@ -7,6 +7,7 @@ export const load: PageServerLoad = async () => {
|
||||
const builds = await (await database.prepare(`
|
||||
SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
|
||||
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
||||
user_profiles.public_slug,
|
||||
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
|
||||
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
|
||||
user_avatars.updated_at AS avatar_updated_at,
|
||||
@@ -26,6 +27,7 @@ export const load: PageServerLoad = async () => {
|
||||
builds: builds.map((build) => ({
|
||||
...build,
|
||||
ownerId: String(build.owner_id),
|
||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||
authorAvatarUrl: avatarUrl(
|
||||
build.avatar_source,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<article style="--build-color:#f6a821">
|
||||
<div class="card-top"><span>PUBLIC</span><span>{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}</span></div>
|
||||
<div class="weapon-art"><div></div><span>SHD LOADOUT</span></div>
|
||||
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><AgentIdentityLink size="compact" linked={build.profileAvailable} agent={{ id: build.ownerId, displayName: String(build.author), avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
|
||||
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><AgentIdentityLink size="compact" linked={build.profileAvailable} agent={{ id: build.ownerId, displayName: String(build.author), publicSlug: build.authorPublicSlug, avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
|
||||
</article>
|
||||
{:else}
|
||||
<div class="catalogue-empty"><span>◇</span><strong>NO PUBLIC BUILDS YET</strong><p>Be the first agent to publish a loadout to the archive.</p><a class="primary" href="/builder">CREATE BUILD →</a></div>
|
||||
|
||||
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const viewer = await getSessionUser(cookies);
|
||||
const build = await (await database.prepare(`
|
||||
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
||||
user_profiles.public_slug,
|
||||
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
|
||||
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
|
||||
user_avatars.updated_at AS avatar_updated_at
|
||||
@@ -45,6 +46,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
id: String(build.id), title: String(build.title), slug: String(build.slug),
|
||||
visibility: String(build.visibility), author: String(build.author),
|
||||
ownerId: String(build.owner_id),
|
||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||
authorAvatarUrl: avatarUrl(
|
||||
build.avatar_source,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
|
||||
<main class="app-page shared-build-page">
|
||||
<header class="shared-build-command">
|
||||
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
|
||||
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
|
||||
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
||||
</header>
|
||||
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { databaseCategory } from '$lib/database/categories';
|
||||
import type { DatabaseFact, DatabaseItem } from '$lib/database/items';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import { weaponBaseRange } from '$lib/builder/loadout';
|
||||
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
|
||||
@@ -33,6 +34,54 @@ const humanized = (value: unknown) => String(value ?? '')
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
|
||||
const talentCardItem = (
|
||||
row: Record<string, any>,
|
||||
mechanics: Array<Record<string, any>>,
|
||||
weaponSlotOverride?: number
|
||||
): DatabaseItem => {
|
||||
const talentCategory = String(row.category);
|
||||
const isWeaponTalent = talentCategory === 'Weapon';
|
||||
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
|
||||
const weaponSlot = weaponSlotOverride ?? (row.weapon_slot ? Number(row.weapon_slot) : undefined);
|
||||
const slotLabel = isWeaponTalent && weaponSlot ? `WEAPON ${weaponSlot}` : normalizedCategory.toUpperCase();
|
||||
const activation = [...new Map(mechanics.map((mechanic) => [
|
||||
String(mechanic.clause_id),
|
||||
`${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}`
|
||||
])).values()].join(' / ');
|
||||
const afterLines: DatabaseFact[] = [
|
||||
...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []),
|
||||
...(row.duration_seconds ? [{ label: 'EFFECT DURATION', value: `${number(row.duration_seconds)} SECONDS` }] : []),
|
||||
...(Number(row.max_stacks) > 1 ? [{ label: 'MAXIMUM STACKS', value: String(row.max_stacks) }] : []),
|
||||
...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [])
|
||||
];
|
||||
const filterKey = isWeaponTalent ? 'weaponTalent' : 'talent';
|
||||
const destination = isWeaponTalent ? 'weapons' : 'gear';
|
||||
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name),
|
||||
kicker: `${slotLabel} TALENT`,
|
||||
variant: 'talent',
|
||||
iconUrl: iconUrl('talent', row.id, row.icon_updated_at ?? row.updated_at),
|
||||
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
|
||||
headerBadge: isWeaponTalent && weaponSlot
|
||||
? `${weaponSlot} · ${weaponSlot === 1 ? 'TOP' : 'BOTTOM'}`
|
||||
: undefined,
|
||||
badges: [
|
||||
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : [])
|
||||
],
|
||||
facts: [],
|
||||
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
|
||||
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
|
||||
descriptionStyle: 'data',
|
||||
afterLines,
|
||||
actions: [{
|
||||
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
|
||||
href: `/database/${destination}?${filterKey}=${encodeURIComponent(String(row.id))}`
|
||||
}]
|
||||
};
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const category = databaseCategory(params.category);
|
||||
if (!category) error(404, 'Database category not found');
|
||||
@@ -344,7 +393,16 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const talentMechanics = category.slug === 'talents' && rows.length
|
||||
const talentIds = [...new Set(
|
||||
category.slug === 'talents'
|
||||
? rows.map((row) => String(row.id))
|
||||
: category.slug === 'weapons'
|
||||
? weaponRelations.map((relation) => String(relation.id))
|
||||
: category.slug === 'gear'
|
||||
? gearRelations.filter((relation) => relation.kind === 'talent').map((relation) => String(relation.id))
|
||||
: []
|
||||
)];
|
||||
const talentMechanics = talentIds.length
|
||||
? await (await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
|
||||
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
|
||||
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
|
||||
@@ -356,9 +414,10 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
||||
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
|
||||
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
|
||||
WHERE rules.source_type='talent' AND rules.configured=1
|
||||
AND rules.source_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...rows.map((row) => row.id))
|
||||
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
|
||||
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...talentIds)
|
||||
: [];
|
||||
const mechanicsFor = (talentId: unknown) => talentMechanics.filter((mechanic) => String(mechanic.talent_id) === String(talentId));
|
||||
|
||||
const items = rows.map((row) => {
|
||||
switch (category.slug) {
|
||||
@@ -383,21 +442,10 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
||||
label: `${effect.pieces_required} PIECES`,
|
||||
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
|
||||
}))
|
||||
} : {
|
||||
id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`,
|
||||
iconUrl: relationIcon,
|
||||
badges: [
|
||||
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
|
||||
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
|
||||
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [
|
||||
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
|
||||
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
|
||||
],
|
||||
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
|
||||
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
|
||||
}
|
||||
} : talentCardItem({
|
||||
...relation,
|
||||
icon_updated_at: relation.updated_at
|
||||
}, mechanicsFor(relation.id))
|
||||
};
|
||||
});
|
||||
const iconGroups = [
|
||||
@@ -428,21 +476,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
||||
id: String(relation.id),
|
||||
name: String(relation.name),
|
||||
iconUrl: talentIcon,
|
||||
preview: {
|
||||
id: String(relation.id), name: String(relation.name), kicker: `WEAPON TALENT // SLOT ${slot}`,
|
||||
iconUrl: talentIcon,
|
||||
badges: [
|
||||
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
|
||||
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
|
||||
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [
|
||||
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
|
||||
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
|
||||
],
|
||||
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
|
||||
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
|
||||
}
|
||||
preview: talentCardItem(relation, mechanicsFor(relation.id), slot)
|
||||
};
|
||||
});
|
||||
const level50Damage = row.damage_min === null ? null : weaponBaseRange(row.damage_min, row.damage_max, 50);
|
||||
@@ -515,77 +549,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
||||
],
|
||||
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
|
||||
};
|
||||
case 'talents': {
|
||||
const talentCategory = String(row.category);
|
||||
const isWeaponTalent = talentCategory === 'Weapon';
|
||||
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
|
||||
const slotLabel = isWeaponTalent && row.weapon_slot ? `WEAPON ${row.weapon_slot}` : normalizedCategory.toUpperCase();
|
||||
const mechanics = talentMechanics.filter((mechanic) => mechanic.talent_id === row.id);
|
||||
const rule = mechanics[0];
|
||||
const groupedOutcomes = new Map<string, any>();
|
||||
for (const outcome of mechanics.filter((mechanic) => mechanic.variant_key)) {
|
||||
const key = [
|
||||
outcome.outcome_key,
|
||||
outcome.application_kind,
|
||||
outcome.effect_scope,
|
||||
outcome.attribute_name ?? '',
|
||||
outcome.damage_basis ?? ''
|
||||
].join('|');
|
||||
const current = groupedOutcomes.get(key) ?? {
|
||||
outcomeKey: String(outcome.outcome_key),
|
||||
application: String(outcome.application_kind),
|
||||
scope: String(outcome.effect_scope),
|
||||
attribute: String(outcome.attribute_name ?? ''),
|
||||
unit: outcome.attribute_unit,
|
||||
damageBasis: outcome.damage_basis,
|
||||
values: {}
|
||||
};
|
||||
current.values[String(outcome.variant_key)] = Number(outcome.value);
|
||||
groupedOutcomes.set(key, current);
|
||||
}
|
||||
const outcomeSummary = [...groupedOutcomes.values()].map((outcome) => {
|
||||
const subject = outcome.application === 'active_modifier'
|
||||
? outcome.attribute
|
||||
: outcome.application === 'direct_damage'
|
||||
? `Direct Damage · ${outcome.damageBasis === 'weapon_base_percent' ? 'Weapon Base' : 'Flat'}`
|
||||
: humanized(outcome.application);
|
||||
const values = ['I', 'II']
|
||||
.filter((variant) => Number.isFinite(outcome.values[variant]))
|
||||
.map((variant) => {
|
||||
const raw = Number(outcome.values[variant]);
|
||||
const prefix = outcome.application === 'active_modifier' && raw > 0 ? '+' : '';
|
||||
return `T${variant} ${prefix}${valueWithUnit(raw, outcome.unit)}`;
|
||||
})
|
||||
.join(' · ');
|
||||
return `${subject} · ${values}`;
|
||||
}).join(' / ');
|
||||
const activation = [...new Map(mechanics.map((mechanic) => [
|
||||
String(mechanic.clause_id),
|
||||
`${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}`
|
||||
])).values()].join(' / ');
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${slotLabel} TALENT`,
|
||||
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
|
||||
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
|
||||
headerBadge: isWeaponTalent && row.weapon_slot
|
||||
? `${row.weapon_slot} · ${Number(row.weapon_slot) === 1 ? 'TOP' : 'BOTTOM'}`
|
||||
: undefined,
|
||||
badges: [
|
||||
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : []),
|
||||
...(row.duration_seconds ? [{ label: `${number(row.duration_seconds)}s DURATION` }] : []),
|
||||
...(row.max_stacks ? [{ label: `${row.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [],
|
||||
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
|
||||
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
|
||||
descriptionStyle: 'data' as const,
|
||||
afterLines: [
|
||||
...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []),
|
||||
...(outcomeSummary ? [{ label: 'CALCULATION EFFECTS', value: outcomeSummary }] : []),
|
||||
...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [])
|
||||
]
|
||||
};
|
||||
}
|
||||
case 'talents':
|
||||
return talentCardItem(row, mechanicsFor(row.id));
|
||||
case 'brands': {
|
||||
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { agentProfileHref } from '$lib/agents';
|
||||
|
||||
const COMMENT_PERMISSION = 'community.comments.moderate';
|
||||
const PROFILE_PERMISSION = 'community.profiles.moderate';
|
||||
@@ -27,9 +28,12 @@ const requiredReason = (value: FormDataEntryValue | null) => {
|
||||
return reason.length >= 3 ? reason : null;
|
||||
};
|
||||
|
||||
const targetHref = (targetType: string, targetId: unknown, slug: unknown) =>
|
||||
const targetHref = (targetType: string, targetId: unknown, slug: unknown, publicSlug?: unknown) =>
|
||||
targetType === 'profile'
|
||||
? `/agents/${encodeURIComponent(String(targetId))}#profile-comments`
|
||||
? agentProfileHref({
|
||||
id: String(targetId),
|
||||
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
|
||||
}, 'profile-comments')
|
||||
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
|
||||
|
||||
const parseAuditSnapshot = (value: unknown) => {
|
||||
@@ -77,15 +81,21 @@ const queueTargetHref = (
|
||||
contentId: unknown,
|
||||
targetType: unknown,
|
||||
targetId: unknown,
|
||||
slug: unknown
|
||||
slug: unknown,
|
||||
publicSlug?: unknown
|
||||
) => {
|
||||
if (contentType === 'profile_bio') return `/agents/${encodeURIComponent(String(contentId))}`;
|
||||
if (contentType === 'profile_bio') {
|
||||
return agentProfileHref({
|
||||
id: String(contentId),
|
||||
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
|
||||
});
|
||||
}
|
||||
if (contentType === 'guide') {
|
||||
return slug
|
||||
? `/builds/${encodeURIComponent(String(slug))}`
|
||||
: `/builds/${encodeURIComponent(String(contentId))}`;
|
||||
}
|
||||
return targetHref(String(targetType), targetId, slug);
|
||||
return targetHref(String(targetType), targetId, slug, publicSlug);
|
||||
};
|
||||
|
||||
const queueActionFailure = (status: number, message: string) => fail(status, {
|
||||
@@ -235,6 +245,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const scanQueue = `SELECT
|
||||
scan.id AS queue_id,'scan' AS item_source,scan.content_type,scan.content_id,
|
||||
scan.author_id,COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
|
||||
author_profile.public_slug AS author_public_slug,
|
||||
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
|
||||
author_avatar.updated_at AS avatar_updated_at,
|
||||
COALESCE(revision.body_markdown,comments.body_markdown,target_build.guide_body,'') AS body_markdown,
|
||||
@@ -249,7 +260,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
THEN COALESCE(target_build.title,'Build guide')
|
||||
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
||||
END AS target_title,
|
||||
target_build.slug
|
||||
target_build.slug,COALESCE(target_profile.public_slug,author_profile.public_slug) AS target_public_slug
|
||||
FROM content_moderation_scans scan
|
||||
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
|
||||
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
|
||||
@@ -271,6 +282,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
report.id AS queue_id,'report' AS item_source,report.content_type,report.content_id,
|
||||
COALESCE(comments.author_id,profile.user_id,target_build.owner_id) AS author_id,
|
||||
COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
|
||||
author_profile.public_slug AS author_public_slug,
|
||||
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
|
||||
author_avatar.updated_at AS avatar_updated_at,
|
||||
COALESCE(comments.body_markdown,profile.bio_markdown,target_build.guide_body,'') AS body_markdown,
|
||||
@@ -287,7 +299,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
THEN COALESCE(target_build.title,'Build guide')
|
||||
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
||||
END AS target_title,
|
||||
target_build.slug
|
||||
target_build.slug,COALESCE(target_profile.public_slug,profile.public_slug) AS target_public_slug
|
||||
FROM user_content_reports report
|
||||
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
|
||||
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
@@ -387,11 +399,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
row.content_id,
|
||||
row.target_type,
|
||||
row.target_id,
|
||||
row.slug
|
||||
row.slug,
|
||||
row.target_public_slug
|
||||
),
|
||||
author: {
|
||||
id: String(row.author_id ?? ''),
|
||||
displayName: String(row.display_name),
|
||||
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
|
||||
avatarUrl: avatarUrl(
|
||||
row.avatar_source,
|
||||
row.avatar_value,
|
||||
@@ -458,6 +472,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
|
||||
comments.created_at,comments.updated_at,comments.removal_reason,
|
||||
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
|
||||
profile.public_slug AS author_public_slug,target_profile.public_slug AS target_public_slug,
|
||||
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
|
||||
thread.target_type,thread.target_id,builds.slug,
|
||||
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
|
||||
@@ -477,10 +492,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
|
||||
targetType: String(row.target_type),
|
||||
targetTitle: String(row.target_title),
|
||||
targetHref: targetHref(String(row.target_type), row.target_id, row.slug),
|
||||
targetHref: targetHref(String(row.target_type), row.target_id, row.slug, row.target_public_slug),
|
||||
author: {
|
||||
id: String(row.author_id),
|
||||
displayName: String(row.display_name),
|
||||
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
|
||||
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.author_id), row.avatar_updated_at)
|
||||
}
|
||||
})),
|
||||
@@ -521,7 +537,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
||||
${where}`)).get(...args))?.count ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const rows = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,profile.is_public,
|
||||
const rows = await (await database.prepare(`SELECT profile.user_id,profile.public_slug,profile.bio_markdown,profile.is_public,
|
||||
profile.updated_at,COALESCE(profile.display_name,users.username) AS display_name,
|
||||
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
|
||||
moderation.reason AS moderation_reason
|
||||
@@ -545,6 +561,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
agent: {
|
||||
id: String(row.user_id),
|
||||
displayName: String(row.display_name),
|
||||
publicSlug: row.public_slug ? String(row.public_slug) : null,
|
||||
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.user_id), row.avatar_updated_at)
|
||||
}
|
||||
})),
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
|
||||
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
|
||||
import { agentProfileHref } from '$lib/agents';
|
||||
|
||||
type AgentSummary = { id: string; displayName: string; publicSlug?: string | null; avatarUrl: string };
|
||||
type CommunityComment = {
|
||||
id: string;
|
||||
bodyHtml: string;
|
||||
@@ -279,7 +281,7 @@
|
||||
<a href={item.targetHref}>{item.targetTitle} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
|
||||
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={agentProfileHref(item.agent)}>VIEW AGENT PROFILE →</a></div>
|
||||
{/if}
|
||||
<div class="community-markdown-preview">
|
||||
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
MANAGED_IMAGE_CATEGORIES,
|
||||
ManagedImageDeletionError,
|
||||
ManagedImageValidationError,
|
||||
deleteManagedImage,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
saveManagedImageFile
|
||||
@@ -124,5 +126,25 @@ export const actions: Actions = {
|
||||
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const database = await getDatabase();
|
||||
let deleted;
|
||||
try {
|
||||
deleted = await deleteManagedImage(database, id);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageDeletionError) return fail(409, { imageError: error.message });
|
||||
throw error;
|
||||
}
|
||||
if (!deleted) return fail(404, { imageError: 'Image not found.' });
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'system_configuration',
|
||||
action: 'image.deleted', entityId: id,
|
||||
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
|
||||
after: { libraryImage: null, sourceIcon: null }
|
||||
});
|
||||
return { imageDeleted: true, resultToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
@@ -8,16 +9,35 @@
|
||||
let isIcon = $state(true);
|
||||
let resetToken = $state(0);
|
||||
let consumedToken = $state<string | null>(null);
|
||||
let deletionTarget = $state<any>(null);
|
||||
let deletionTrigger = $state<HTMLButtonElement | null>(null);
|
||||
let deletionCancel = $state<HTMLButtonElement | null>(null);
|
||||
const size = (bytes: unknown) => {
|
||||
const value = Number(bytes ?? 0);
|
||||
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
};
|
||||
const sourceLabel = (value: unknown) => String(value ?? '').replaceAll('_', ' ').toLocaleUpperCase();
|
||||
const openDeletion = async (image: any, trigger: HTMLButtonElement) => {
|
||||
deletionTarget = image;
|
||||
deletionTrigger = trigger;
|
||||
await tick();
|
||||
deletionCancel?.focus();
|
||||
};
|
||||
const closeDeletion = async () => {
|
||||
const trigger = deletionTrigger;
|
||||
deletionTarget = null;
|
||||
deletionTrigger = null;
|
||||
await tick();
|
||||
trigger?.focus();
|
||||
};
|
||||
$effect(() => {
|
||||
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
|
||||
if (form?.resultToken && form.resultToken !== consumedToken) {
|
||||
consumedToken = form.resultToken;
|
||||
if (form.editorMode === 'edit') editing = null;
|
||||
else {
|
||||
if (form.imageDeleted) {
|
||||
deletionTarget = null;
|
||||
deletionTrigger = null;
|
||||
} else if (form.imageSaved && form.editorMode === 'edit') editing = null;
|
||||
else if (form.imageSaved) {
|
||||
adding = true;
|
||||
isIcon = true;
|
||||
resetToken += 1;
|
||||
@@ -26,6 +46,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && deletionTarget) void closeDeletion(); }}/>
|
||||
|
||||
<svelte:head><title>Image library — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar">
|
||||
<div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>IMAGE LIBRARY</h2><p>Process, classify, and locate reusable SHD.build visual assets.</p></div>
|
||||
@@ -81,15 +103,36 @@
|
||||
<section class="image-library-grid" aria-label="Managed image library">
|
||||
{#each data.images as image}
|
||||
<article class:icon={image.is_icon}>
|
||||
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}"/></div>
|
||||
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}" loading="lazy" decoding="async"/></div>
|
||||
<header>
|
||||
<div><strong>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
|
||||
<b>{image.source_entity_type ? 'ASSIGNED ICON' : image.is_icon ? 'ICON' : 'IMAGE'}</b>
|
||||
<div><strong title={image.name}>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
|
||||
<b class:assigned={image.source_entity_type}>{image.source_entity_type ? 'ASSIGNED' : image.is_icon ? 'ICON' : 'IMAGE'}</b>
|
||||
</header>
|
||||
<div class="image-library-tags">{#each image.tags as tag}<span>{tag}</span>{/each}{#if !image.tags.length}<small>NO TAGS</small>{/if}</div>
|
||||
<footer><span>{image.width} × {image.height} · {size(image.byte_size)}</span><button class="table-action" type="button" onclick={() => { editing = image; adding = false; }}>EDIT →</button></footer>
|
||||
<footer class="image-library-facts">
|
||||
<span>{image.width} × {image.height} · {size(image.byte_size)}</span>
|
||||
<span>{image.source_entity_type ? `LINKED · ${sourceLabel(image.source_entity_type)}` : 'LIBRARY UPLOAD'}</span>
|
||||
</footer>
|
||||
<div class="image-library-actions" aria-label={`Actions for ${image.name}`}>
|
||||
<a class="table-action" href={image.url} download={`${image.name}.webp`}>DOWNLOAD</a>
|
||||
<button class="table-action edit" type="button" onclick={() => { editing = image; adding = false; }}>EDIT</button>
|
||||
<button class="table-action destructive" type="button" aria-haspopup="dialog" onclick={(event) => void openDeletion(image, event.currentTarget)}>DELETE</button>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<div class="image-library-empty"><strong>NO MATCHING ASSETS</strong><span>Upload the first image or revise the registry search.</span></div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
{#if deletionTarget}
|
||||
<div class="isac-modal-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closeDeletion(); }}>
|
||||
<div class="isac-confirm-modal image-library-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="image-delete-title" aria-describedby="image-delete-description">
|
||||
<div class="editor-title"><div><p class="eyebrow">PERMANENT ASSET REMOVAL</p><h3 id="image-delete-title">DELETE {deletionTarget.name.toLocaleUpperCase()}?</h3></div><button type="button" aria-label="Cancel image deletion" onclick={() => void closeDeletion()}>×</button></div>
|
||||
<div class="image-library-delete-summary">
|
||||
<div class="image-library-delete-preview"><img src={deletionTarget.url} alt=""/></div>
|
||||
<div id="image-delete-description"><b>{deletionTarget.source_entity_type ? `ASSIGNED TO ${sourceLabel(deletionTarget.source_entity_type)}` : 'LIBRARY UPLOAD'}</b><p>{deletionTarget.source_entity_type ? `ISAC will clear the icon from its ${sourceLabel(deletionTarget.source_entity_type)} record, then permanently delete the shared image file.` : 'ISAC will permanently delete the stored image file.'}</p><small>This action cannot be undone.</small></div>
|
||||
</div>
|
||||
<div class="image-library-delete-actions"><button class="secondary" type="button" bind:this={deletionCancel} onclick={() => void closeDeletion()}>CANCEL</button><PendingForm action="?/delete" successMessage="Image deleted."><input type="hidden" name="id" value={deletionTarget.id}/><button class="danger">CONFIRM DELETE</button></PendingForm></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -8,6 +8,17 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
|
||||
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
||||
const seededRoleIds = new Set([
|
||||
'role-data-editor',
|
||||
'role-user-operator',
|
||||
'role-access-manager',
|
||||
'role-admin',
|
||||
'role-moderator',
|
||||
'role-data-viewer',
|
||||
'role-data-contributor',
|
||||
'role-data-approver',
|
||||
'role-auditor'
|
||||
]);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
@@ -68,7 +79,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
roles: roles.map((role) => ({
|
||||
...role,
|
||||
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
|
||||
protected: role.id === 'role-admin'
|
||||
systemRole: seededRoleIds.has(String(role.id)),
|
||||
protected: role.id === 'role-admin',
|
||||
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
|
||||
canDelete: role.id !== 'role-admin'
|
||||
&& (canManageSystemAdmin || !seededRoleIds.has(String(role.id)))
|
||||
})),
|
||||
permissions,
|
||||
summary,
|
||||
@@ -95,7 +110,9 @@ export const actions: Actions = {
|
||||
const database = await getDatabase();
|
||||
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
|
||||
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
|
||||
if (id === 'role-admin') return fail(403, { roleError: 'The built-in Admin role is protected and cannot be modified.' });
|
||||
if (id === 'role-admin' && !canManageSystemAdmin) {
|
||||
return fail(403, { roleError: 'Only a full administrator can modify the core Admin role.' });
|
||||
}
|
||||
if (await (await database.prepare('SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { roleError: 'A role with that name already exists.' });
|
||||
}
|
||||
@@ -119,5 +136,79 @@ export const actions: Actions = {
|
||||
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
|
||||
});
|
||||
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
|
||||
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
|
||||
if (id === 'role-admin') {
|
||||
return fail(403, { roleDeleteError: 'The core Admin role cannot be deleted.' });
|
||||
}
|
||||
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
|
||||
return fail(403, { roleDeleteError: 'Only a full administrator can delete a seeded system role.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const role = await (await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?'
|
||||
)).get(id);
|
||||
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
|
||||
if (confirmation !== String(role.name)) {
|
||||
return fail(400, { roleDeleteError: 'Enter the exact role name to confirm deletion.' });
|
||||
}
|
||||
const [permissions, assignedUsers] = await Promise.all([
|
||||
(await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)).all(id),
|
||||
(await database.prepare(`SELECT users.id,
|
||||
COALESCE(user_profiles.display_name,users.username) AS display_name
|
||||
FROM user_roles JOIN users ON users.id=user_roles.user_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
WHERE user_roles.role_id=?
|
||||
ORDER BY display_name COLLATE NOCASE,users.id`)).all(id)
|
||||
]);
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (seededRoleIds.has(id)) {
|
||||
await (await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(role_id) DO UPDATE SET
|
||||
deleted_at=excluded.deleted_at,
|
||||
deleted_by=excluded.deleted_by`))
|
||||
.run(id, new Date().toISOString(), operator.id);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(id);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(id);
|
||||
const deleted = await (await database.prepare('DELETE FROM roles WHERE id=?')).run(id);
|
||||
if (!deleted.changes) throw new Error('The role changed before it could be deleted.');
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: 'role.deleted',
|
||||
entityId: id,
|
||||
before: {
|
||||
name: String(role.name),
|
||||
description: role.description ? String(role.description) : null,
|
||||
permissions: permissions.map((row) => String(row.name)),
|
||||
assignedUsers: assignedUsers.map((row) => ({
|
||||
id: String(row.id),
|
||||
displayName: String(row.display_name)
|
||||
}))
|
||||
},
|
||||
after: {
|
||||
deleted: true,
|
||||
systemRole: seededRoleIds.has(id),
|
||||
removedAssignmentCount: assignedUsers.length
|
||||
}
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { roleDeleted: true, resultToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
|
||||
let { data, form } = $props();
|
||||
let editing = $state<any>(null);
|
||||
let deleting = $state<any>(null);
|
||||
let handledResultToken = $state('');
|
||||
const blank = () => ({ id: '', name: '', description: '', permissionIds: [] });
|
||||
const permissionName = (id: string) => data.permissions.find((permission) => permission.id === id)?.name ?? id;
|
||||
$effect(() => {
|
||||
if (!form?.roleSaved || !form.resultToken || form.resultToken === handledResultToken) return;
|
||||
if ((!form?.roleSaved && !form?.roleDeleted) || !form.resultToken || form.resultToken === handledResultToken) return;
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.roleDeleted) {
|
||||
deleting = null;
|
||||
editing = null;
|
||||
return;
|
||||
}
|
||||
editing = form.roleMode === 'add' ? blank() : null;
|
||||
});
|
||||
</script>
|
||||
@@ -54,7 +60,37 @@
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row role-row table-heading"><SortableColumnHeader label="ROLE" column="name" sort={data.sort}/><SortableColumnHeader label="ASSIGNED USERS" column="users" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="DISCRETE PERMISSIONS" column="permissions" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort}/><span></span></div>
|
||||
{#each data.roles as role}
|
||||
<div class="dashboard-row role-row" class:registry-row-editing={editing?.id === role.id}><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}</span>{#if role.protected}<span></span>{:else}<button aria-expanded={editing?.id === role.id} aria-controls={`role-editor-${role.id}`} onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row role-row" class:registry-row-editing={editing?.id === role.id}><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'CORE ROLE' : role.systemRole ? 'SYSTEM ROLE' : 'CUSTOM ROLE'}</span>{#if !role.canEdit && !role.canDelete}<span></span>{:else}<div class="role-row-actions">{#if role.canEdit}<button aria-expanded={editing?.id === role.id} aria-controls={`role-editor-${role.id}`} onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}{#if role.canDelete}<button class="table-action destructive" type="button" aria-haspopup="dialog" onclick={() => deleting = role}>DELETE</button>{/if}</div>{/if}</div>
|
||||
{#if editing?.id === role.id}<RegistryInlineEditor id={`role-editor-${role.id}`} label={`Edit ${role.name} role`}>{@render roleEditor()}</RegistryInlineEditor>{/if}
|
||||
{:else}<div class="empty-dashboard"><strong>NO ROLES MATCH THIS FILTER</strong><span>Select another access domain or create a role.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
{#if deleting}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal role-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="role-delete-title">
|
||||
<div class="editor-title"><div><p class="eyebrow">DESTRUCTIVE ACCESS CHANGE</p><h3 id="role-delete-title">DELETE {deleting.name}?</h3></div><button type="button" aria-label="Close role deletion confirmation" onclick={() => deleting = null}>×</button></div>
|
||||
<div class="role-delete-consequence">
|
||||
<b>THIS CANNOT BE UNDONE</b>
|
||||
<p>{Number(deleting.user_count) === 0 ? 'No agents are assigned to this role.' : `${deleting.user_count} agent${Number(deleting.user_count) === 1 ? '' : 's'} will immediately lose this role and its permissions.`}</p>
|
||||
</div>
|
||||
<PendingForm action="?/delete">
|
||||
<input type="hidden" name="id" value={deleting.id}/>
|
||||
<label class="field">TYPE “{deleting.name}” TO CONFIRM<input name="confirmation" required autocomplete="off"/></label>
|
||||
{#if form?.roleDeleteError}<p class="error">{form.roleDeleteError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => deleting = null}>CANCEL</button><button class="danger">DELETE ROLE</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.role-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:18px}
|
||||
.role-delete-dialog{width:min(620px,calc(100vw - 28px))}
|
||||
.role-delete-consequence{display:grid;gap:5px;padding:14px;border-bottom:1px solid var(--shd-border)}
|
||||
.role-delete-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}
|
||||
.role-delete-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
|
||||
.role-delete-dialog :global(form){display:grid;gap:10px;padding:14px}
|
||||
.role-delete-dialog :global(.field){margin:0}
|
||||
.role-delete-dialog :global(.form-actions){padding-top:4px}
|
||||
@media(max-width:650px){.role-row-actions{align-items:stretch;flex-direction:column;gap:7px}.role-row-actions button{min-height:44px}.role-delete-dialog :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
let { data } = $props();
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
@@ -11,6 +12,6 @@
|
||||
<RegistryPagination pagination={data.pagination} search={data.query} searchPlaceholder="SEARCH DISPLAY OR LOGIN NAME"/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><SortableColumnHeader label="USER" column="user" sort={data.sort}/><SortableColumnHeader label="ACCESS" column="access" sort={data.sort}/><SortableColumnHeader label="ROLES" column="roles" sort={data.sort}/><SortableColumnHeader label="LAST LOGIN" column="lastLogin" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="LAST ACTIVE" column="lastActive" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="CONTENT" column="content" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><RoleBadgeList roles={String(user.assigned_roles ?? user.role ?? 'User').split(' · ')} label={`Assigned roles for ${user.display_name}`} compact/><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,10 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
||||
import { getSiteAccessSettings } from '$lib/server/site-access';
|
||||
import {
|
||||
displayNameIsAvailable,
|
||||
insertUserProfile
|
||||
} from '$lib/server/agent-profiles';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const { accountCreationEnabled } = await getSiteAccessSettings();
|
||||
@@ -55,8 +59,7 @@ export const actions: Actions = {
|
||||
const id = randomUUID(), now = new Date().toISOString();
|
||||
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
|
||||
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, 'New Agent', now);
|
||||
await insertUserProfile(database, id, 'Agent', now, { placeholder: true });
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
identity = { user_id: id, created_at: null };
|
||||
@@ -101,11 +104,13 @@ export const actions: Actions = {
|
||||
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) {
|
||||
return fail(409, { registerError: 'That login username is already in use.', ...values });
|
||||
}
|
||||
if (!(await displayNameIsAvailable(database, displayName))) {
|
||||
return fail(409, { registerError: 'That display name is already in use.', ...values });
|
||||
}
|
||||
const id = randomUUID(), now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
|
||||
VALUES(?,?,?,?,?,?)`)).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, username, await hashPassword(password), 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, displayName, now);
|
||||
await insertUserProfile(database, id, displayName, now);
|
||||
await ensureBootstrapAdmin(database, id);
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
|
||||
@@ -111,11 +111,13 @@
|
||||
Private passkey material remains on your authenticator.
|
||||
</p>
|
||||
<p>
|
||||
A public Agent profile displays your public display name, avatar, biography, public
|
||||
builds, published guides, recent public discussion, and profile messages. Other
|
||||
signed-in users may post messages on a public profile when their account has
|
||||
permission to do so. Profile messages display the author's public identity and may
|
||||
be removed by their author or Community Oversight.
|
||||
A public Agent profile displays your public display name, avatar, assigned site-role
|
||||
names, biography, public builds, published guides, recent public discussion, and
|
||||
profile messages. SHD.build does not display role permissions, descriptions, or
|
||||
direct permission grants on public profiles. Other signed-in users may post messages
|
||||
on a public profile when their account has permission to do so. Profile messages
|
||||
display the author's public identity and may be removed by their author or Community
|
||||
Oversight.
|
||||
</p>
|
||||
<p>
|
||||
You may hide your Agent profile from public discovery and prevent new profile
|
||||
|
||||
Reference in New Issue
Block a user