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
|
focus-visible, selected, disabled, pressed, responsive, and reduced-motion
|
||||||
behavior in shared controls.
|
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
|
## Prototype compatibility
|
||||||
|
|
||||||
Treat application data and client payloads as prototype formats until the
|
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 (
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
|
display_name_key TEXT,
|
||||||
|
public_slug TEXT,
|
||||||
bio_markdown TEXT NOT NULL DEFAULT '',
|
bio_markdown TEXT NOT NULL DEFAULT '',
|
||||||
is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1)),
|
is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1)),
|
||||||
bio_updated_at TEXT,
|
bio_updated_at TEXT,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO user_profiles(user_id,display_name,updated_at) SELECT id,username,created_at FROM users;
|
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 (
|
CREATE TABLE IF NOT EXISTS profile_moderation (
|
||||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
bio_hidden INTEGER NOT NULL DEFAULT 0 CHECK(bio_hidden IN (0, 1)),
|
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 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 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 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 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 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));
|
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
|
- On small screens, the split frame becomes a single column and account controls
|
||||||
move before archival content.
|
move before archival content.
|
||||||
- Public display names and private login usernames are distinct. Display names
|
- Public display names and private login usernames are distinct. Display names
|
||||||
support normalized Unicode and relaxed visible characters; login usernames
|
support normalized Unicode and relaxed visible characters. They are unique
|
||||||
remain unique, ASCII-only credentials and must never appear on public-facing
|
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.
|
community surfaces. Authorized ISAC user operations may show both.
|
||||||
|
|
||||||
### Public Agent profiles
|
### 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
|
Public Agent profiles are community dossiers, not account-control screens. Lead
|
||||||
with the shared `AgentIdentityLink` in its large state: circular portrait,
|
with the shared `AgentIdentityLink` in its large state: circular portrait,
|
||||||
display name, explicit `SHD.BUILD AGENT PROFILE` context, and a directional
|
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
|
link cue. Place assigned site-role badges beneath the identity as a labeled
|
||||||
header. Keep public content totals in a compact telemetry strip below the
|
semantic list. Show role names only, in alphabetical order, and omit the list
|
||||||
identity header, then place the field bio, public builds, published guides, and
|
when the Agent has no assigned roles. Badges use the shared raised surface,
|
||||||
recent discussion in the primary column. Profile messages occupy the narrower
|
explicit role text, and a small orange diamond; they wrap without truncating
|
||||||
communications column and may remain sticky where the viewport has room.
|
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
|
Use `AgentIdentityLink` for author attribution on builds, guides, comments, and
|
||||||
other community records. Its compact and default states combine the circular
|
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
|
portrait remains circular at every size; selectors may use square tactical
|
||||||
thumbnails only where the avatar rules explicitly allow them.
|
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
|
Public biographies and community comments use the shared `MarkdownEditor` and
|
||||||
`MarkdownContent` pair. The editor supplies a small formatting toolbar,
|
`MarkdownContent` pair. The editor supplies a small formatting toolbar,
|
||||||
remaining-character count, concise help, a readable editorial typeface, and a
|
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
|
- Roles is a separate User Operations registry. Role definitions
|
||||||
compose discrete capabilities through a dense permission catalogue with an
|
compose discrete capabilities through a dense permission catalogue with an
|
||||||
explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The
|
explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The
|
||||||
built-in Admin role is visible but system-protected. Delegated access managers
|
core Admin role is deletion-protected because it provides the system's
|
||||||
must never be able to grant `system.admin`; both option visibility and
|
unrestricted access mechanism. Full administrators may edit every role and
|
||||||
server-side validation enforce this boundary. Role mutations are recorded in
|
delete seeded operational roles; deletion records a persistent tombstone so
|
||||||
the audit log with their previous and resulting permission sets.
|
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 History belongs to a separate `SYSTEM OVERSIGHT` navigation domain.
|
||||||
Audit records are append-only operational evidence and show the operator,
|
Audit records are append-only operational evidence and show the operator,
|
||||||
categorical action, target identifier, timestamp, and expandable before/after
|
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.
|
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
|
Each icon retains its accessible name and hover title, and missing artwork uses
|
||||||
the shared placeholder without suppressing the relationship. Relationship
|
the shared placeholder without suppressing the relationship. Relationship
|
||||||
icons are buttons: activating one opens a modal quick view using the same card
|
icons are buttons: activating one opens a modal quick view with the shared
|
||||||
facts as that record's public database category. The overlay closes through
|
database card component and the same facts, tier hierarchy, mechanic rows, and
|
||||||
its explicit close action, backdrop, or Escape key. Do not render an empty
|
actions shown in that record's public registry. The overlay traps keyboard
|
||||||
Talent rail on item types that cannot equip Talents.
|
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
|
### 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
|
Talent registry cards treat the two tier descriptions as their primary
|
||||||
content; never replace them with configured/missing summary facts. Render Tier
|
content; never replace them with configured/missing summary facts. Render Tier
|
||||||
I and Tier II as distinct structured data blocks, then place optional cooldown
|
I and Tier II as distinct structured data blocks, then place mechanics metadata
|
||||||
metadata below both descriptions. Do not use the serif, quote-like prose
|
below both descriptions. Use 16px semibold sans-serif text for tier effects so
|
||||||
treatment for tier effects. Use compact fixed tier labels, sans-serif effect
|
players can scan them before supporting facts. Do not use the serif,
|
||||||
text, and engineered borders. Tier I is the neutral baseline; Tier II uses the
|
quote-like prose treatment. Use compact fixed tier labels and engineered
|
||||||
orange rail, label, and subtly elevated surface to communicate that it is the
|
borders. Tier I is the neutral baseline; Tier II uses the orange rail, label,
|
||||||
improved version. Duration and stack limits may remain compact badges. The
|
and subtly elevated surface to communicate that it is the improved version.
|
||||||
top-right header status uses the applicable Backpack or Body Armor icon for
|
Render duration and stack limits as detail rows with activation and cooldown
|
||||||
gear talents and the SMG icon as the generic Weapon Talent symbol. Weapon
|
data. Keep calculation effects out of public Talent cards because the tier
|
||||||
Talent headers pair that icon with an explicit `1 · TOP` or `2 · BOTTOM`
|
descriptions already state the player-facing values. Omit a stack limit of one
|
||||||
status badge; never rely on the slot number alone.
|
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
|
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
|
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
|
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
|
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.
|
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) =>
|
const hasExactKeys = (value, keys) =>
|
||||||
isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
||||||
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(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) =>
|
const isRoll = (value) =>
|
||||||
hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value);
|
hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value);
|
||||||
const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
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')) {
|
if (!profileColumns.some((column) => column.name === 'bio_updated_at')) {
|
||||||
await database.exec('ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT');
|
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(
|
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 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 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 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);
|
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(
|
const skillProfileDefinition = await (await database.prepare(
|
||||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'"
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'"
|
||||||
)).get();
|
)).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">
|
<script lang="ts">
|
||||||
|
import { agentProfileHref } from '$lib/agents';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
agent,
|
agent,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
@@ -6,7 +8,7 @@
|
|||||||
showId = false,
|
showId = false,
|
||||||
linked = true
|
linked = true
|
||||||
}: {
|
}: {
|
||||||
agent: { id: string; displayName: string; avatarUrl: string };
|
agent: { id: string; displayName: string; avatarUrl: string; publicSlug?: string | null };
|
||||||
size?: 'compact' | 'default' | 'large';
|
size?: 'compact' | 'default' | 'large';
|
||||||
meta?: string;
|
meta?: string;
|
||||||
showId?: boolean;
|
showId?: boolean;
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if linked}
|
{#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="" />
|
<img src={agent.avatarUrl} alt="" />
|
||||||
<span>
|
<span>
|
||||||
<strong>{agent.displayName}</strong>
|
<strong>{agent.displayName}</strong>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="database-item-card">
|
<article class="database-item-card" class:talent-card={item.variant === 'talent'}>
|
||||||
<header>
|
<header>
|
||||||
<div class="database-item-icon">
|
<div class="database-item-icon">
|
||||||
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
|
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
|
||||||
@@ -41,8 +41,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
{#if item.variant === 'talent'}
|
||||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
<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}
|
{#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>
|
<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}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import DatabaseItemCard from './DatabaseItemCard.svelte';
|
||||||
import ItemIcon from './ItemIcon.svelte';
|
import ItemIcon from './ItemIcon.svelte';
|
||||||
import DatabaseFacts from './DatabaseFacts.svelte';
|
|
||||||
import type { DatabaseRelatedItem } from '$lib/database/items';
|
import type { DatabaseRelatedItem } from '$lib/database/items';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -13,14 +14,45 @@
|
|||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
let selected = $state<DatabaseRelatedItem | null>(null);
|
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>
|
</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}`}>
|
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
|
||||||
{#if items.length}
|
{#if items.length}
|
||||||
{#each items as item}
|
{#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=""/>
|
<ItemIcon src={item.iconUrl} alt=""/>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -30,22 +62,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if selected?.preview}
|
{#if selected?.preview}
|
||||||
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) selected = null; }}>
|
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
|
||||||
<div class="database-preview-dialog" role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`}>
|
<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" aria-label="Close details" onclick={() => selected = null}>×</button></header>
|
<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>
|
||||||
<article class="database-item-card">
|
<DatabaseItemCard item={selected.preview}/>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
kicker: string;
|
kicker: string;
|
||||||
|
variant?: 'talent';
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
gameIcon?: string;
|
gameIcon?: GameIconName;
|
||||||
headerBadge?: string;
|
headerBadge?: string;
|
||||||
headerMetaIcon?: GameIconName;
|
headerMetaIcon?: GameIconName;
|
||||||
badges?: DatabaseBadge[];
|
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 type { Database } from '@tursodatabase/database';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||||
|
import { displayNameKey } from '$lib/agents';
|
||||||
|
|
||||||
export async function softDeleteAccount(
|
export async function softDeleteAccount(
|
||||||
database: Database,
|
database: Database,
|
||||||
@@ -18,6 +19,7 @@ export async function softDeleteAccount(
|
|||||||
if (!user || user.deleted_at) return false;
|
if (!user || user.deleted_at) return false;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
||||||
|
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
||||||
|
|
||||||
await (await database.prepare(
|
await (await database.prepare(
|
||||||
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
|
'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=?`
|
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
|
||||||
)).run(deletedUsername, userId);
|
)).run(deletedUsername, userId);
|
||||||
await (await database.prepare(
|
await (await database.prepare(
|
||||||
`INSERT INTO user_profiles(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
|
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||||
VALUES(?,?,?,0,?,?)
|
VALUES(?,?,?,?,0,?,?)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
display_name=excluded.display_name,
|
display_name=excluded.display_name,
|
||||||
|
display_name_key=excluded.display_name_key,
|
||||||
bio_markdown='',
|
bio_markdown='',
|
||||||
is_public=0,
|
is_public=0,
|
||||||
bio_updated_at=excluded.bio_updated_at,
|
bio_updated_at=excluded.bio_updated_at,
|
||||||
updated_at=excluded.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 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 magic_link_tokens WHERE user_id=?')).run(userId);
|
||||||
await (await database.prepare('DELETE FROM webauthn_challenges 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;
|
username: string;
|
||||||
loginUsername: string | null;
|
loginUsername: string | null;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
publicSlug: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
discordLinked: boolean;
|
discordLinked: boolean;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
@@ -111,6 +112,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const statement = await database.prepare(`
|
const statement = await database.prepare(`
|
||||||
SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name,
|
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,
|
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_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,
|
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),
|
username: String(user.username),
|
||||||
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
|
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
|
||||||
displayName: String(user.display_name),
|
displayName: String(user.display_name),
|
||||||
|
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
|
||||||
role: String(user.role),
|
role: String(user.role),
|
||||||
discordLinked: typeof user.discord_id === 'string',
|
discordLinked: typeof user.discord_id === 'string',
|
||||||
avatarUrl: avatarUrl(user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at)
|
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 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 path from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
|
||||||
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
|
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
|
||||||
|
|
||||||
export const MANAGED_IMAGE_CATEGORIES = [
|
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;
|
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 ManagedImageValidationError extends Error {}
|
||||||
|
export class ManagedImageDeletionError extends Error {}
|
||||||
|
|
||||||
export function managedImageStorageDirectory() {
|
export function managedImageStorageDirectory() {
|
||||||
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
|
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;
|
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) {
|
export async function readManagedImage(database: Database, id: string) {
|
||||||
if (!ID_PATTERN.test(id)) return null;
|
if (!ID_PATTERN.test(id)) return null;
|
||||||
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
|
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,
|
moderateCommunityText,
|
||||||
recordCommunityModerationScan
|
recordCommunityModerationScan
|
||||||
} from '$lib/server/community-moderation';
|
} 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 }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const user = await getSessionUser(cookies);
|
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
|
(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
|
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),
|
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
|
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
|
||||||
FROM user_profiles profile
|
FROM user_profiles profile
|
||||||
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
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,
|
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
|
||||||
pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
|
pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
|
||||||
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_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.' });
|
return fail(400, { displayNameError: 'Use 2–40 visible characters on one line.' });
|
||||||
}
|
}
|
||||||
const now = new Date().toISOString(), database = await getDatabase();
|
const now = new Date().toISOString(), database = await getDatabase();
|
||||||
await (await database.prepare(`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
|
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
|
||||||
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`))
|
return fail(409, { displayNameError: 'That display name is already in use.' });
|
||||||
.run(user.id, displayName, now);
|
}
|
||||||
|
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 };
|
return { displayNameChanged: true };
|
||||||
},
|
},
|
||||||
changePassword: async ({ request, cookies }) => {
|
changePassword: async ({ request, cookies }) => {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
|
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
|
||||||
<main class="page profile-page">
|
<main class="page profile-page">
|
||||||
<section class="profile-command">
|
<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>
|
<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>
|
</section>
|
||||||
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
|
{#if data.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}
|
{/if}
|
||||||
<hr/>
|
<hr/>
|
||||||
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
|
<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/>
|
<hr/>
|
||||||
<p class="eyebrow">FIELD DOSSIER</p><h2>PUBLIC BIO</h2>
|
<p class="eyebrow">FIELD DOSSIER</p><h2>PUBLIC BIO</h2>
|
||||||
{#if data.profile.bioHidden}
|
{#if data.profile.bioHidden}
|
||||||
|
|||||||
@@ -5,16 +5,15 @@ import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
|||||||
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
|
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
|
||||||
import { getPermissions } from '$lib/server/auth/authorization';
|
import { getPermissions } from '$lib/server/auth/authorization';
|
||||||
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
|
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';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const user = await getSessionUser(cookies);
|
const user = await getSessionUser(cookies);
|
||||||
if (!user) redirect(303, '/sign-in');
|
if (!user) redirect(303, '/sign-in');
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const requestedState = String(url.searchParams.get('state') ?? 'all');
|
const state = parseNotificationState(url.searchParams.get('state'));
|
||||||
const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
|
const sort = parseNotificationSort(url.searchParams.get('sort'));
|
||||||
const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
|
|
||||||
const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
|
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
const { search, pattern } = getRegistrySearch(url);
|
||||||
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
|
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
|
||||||
const args: unknown[] = [user.id];
|
const args: unknown[] = [user.id];
|
||||||
@@ -27,11 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
args.push(pattern, pattern, pattern);
|
args.push(pattern, pattern, pattern);
|
||||||
}
|
}
|
||||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||||
const orderBy = {
|
const orderBy = notificationOrderBy(sort);
|
||||||
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 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 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 pagination = getPagination(url, total);
|
||||||
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
|
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
createUserContentReport,
|
createUserContentReport,
|
||||||
recordCommunityModerationScan
|
recordCommunityModerationScan
|
||||||
} from '$lib/server/community-moderation';
|
} from '$lib/server/community-moderation';
|
||||||
import { error, fail } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { queueCurrentAvatarReview } from '$lib/server/avatars';
|
import { queueCurrentAvatarReview } from '$lib/server/avatars';
|
||||||
|
import { agentProfileHref } from '$lib/agents';
|
||||||
|
import { resolveAgentProfile } from '$lib/server/agent-profiles';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
const COMMENT_MAX_CHARACTERS = 2_000;
|
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);
|
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
|
||||||
};
|
};
|
||||||
|
|
||||||
const commentHref = (targetType: string, slug: unknown, targetId: string) => {
|
const commentHref = (targetType: string, slug: unknown, targetId: string, targetPublicSlug: unknown) => {
|
||||||
if (targetType === 'profile') return `/agents/${encodeURIComponent(targetId)}#profile-comments`;
|
if (targetType === 'profile') {
|
||||||
|
return agentProfileHref({
|
||||||
|
id: targetId,
|
||||||
|
publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
|
||||||
|
}, 'profile-comments');
|
||||||
|
}
|
||||||
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
|
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const viewer = await getSessionUser(cookies);
|
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,
|
const agent = await (await database.prepare(`SELECT users.id,
|
||||||
COALESCE(profile.display_name,users.username) AS display_name,
|
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,
|
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,
|
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
|
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_avatars avatar ON avatar.user_id=users.id
|
||||||
LEFT JOIN user_status status ON status.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
|
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||||||
WHERE users.id=?`)).get(params.id);
|
WHERE users.id=?`)).get(agentId);
|
||||||
const isSelf = viewer?.id === params.id;
|
const isSelf = viewer?.id === agentId;
|
||||||
if (
|
if (
|
||||||
unavailable(agent)
|
unavailable(agent)
|
||||||
|| (!Boolean(agent?.is_public) && !isSelf)
|
|| (!Boolean(agent?.is_public) && !isSelf)
|
||||||
) error(404, 'Agent profile not found');
|
) 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 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
|
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
|
||||||
FROM builds WHERE owner_id=? AND visibility='public'
|
FROM builds WHERE owner_id=? AND visibility='public'
|
||||||
AND (guide_body IS NULL OR length(trim(guide_body))=0)
|
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
|
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
|
||||||
FROM builds WHERE owner_id=? AND visibility='public'
|
FROM builds WHERE owner_id=? AND visibility='public'
|
||||||
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
|
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,
|
(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,
|
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
|
FROM comments
|
||||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
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')
|
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_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
|
||||||
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
|
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,
|
(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,
|
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
|
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
|
||||||
FROM comment_threads thread
|
FROM comment_threads thread
|
||||||
JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
|
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_profiles profile ON profile.user_id=users.id
|
||||||
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
||||||
WHERE thread.target_type='profile' AND thread.target_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
|
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
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')
|
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
|
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_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
|
||||||
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
|
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
|
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||||
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
|
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
|
||||||
.get(params.id),
|
.get(agentId),
|
||||||
(await database.prepare(`SELECT
|
(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 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
|
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 {
|
return {
|
||||||
agent: {
|
agent: {
|
||||||
id: String(agent.id),
|
id: String(agent.id),
|
||||||
displayName: String(agent.display_name),
|
displayName: String(agent.display_name),
|
||||||
|
publicSlug: String(agent.public_slug),
|
||||||
avatarUrl: avatarUrl(
|
avatarUrl: avatarUrl(
|
||||||
agent.avatar_source,
|
agent.avatar_source,
|
||||||
agent.avatar_value,
|
agent.avatar_value,
|
||||||
String(agent.id),
|
String(agent.id),
|
||||||
agent.avatar_updated_at
|
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: {
|
counts: {
|
||||||
builds: Number(contentCounts?.builds ?? 0),
|
builds: Number(contentCounts?.builds ?? 0),
|
||||||
@@ -152,7 +173,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
targetTitle: targetType === 'profile'
|
targetTitle: targetType === 'profile'
|
||||||
? `${String(comment.target_display_name ?? 'Agent')} profile`
|
? `${String(comment.target_display_name ?? 'Agent')} profile`
|
||||||
: String(comment.build_title ?? 'Community content'),
|
: 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) => ({
|
profileComments: profileCommentRows.map((comment) => ({
|
||||||
@@ -162,6 +183,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
author: {
|
author: {
|
||||||
id: String(comment.author_id),
|
id: String(comment.author_id),
|
||||||
displayName: String(comment.display_name),
|
displayName: String(comment.display_name),
|
||||||
|
publicSlug: comment.public_slug ? String(comment.public_slug) : null,
|
||||||
avatarUrl: avatarUrl(
|
avatarUrl: avatarUrl(
|
||||||
comment.avatar_source,
|
comment.avatar_source,
|
||||||
comment.avatar_value,
|
comment.avatar_value,
|
||||||
@@ -195,6 +217,9 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const database = await getDatabase();
|
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 moderationPolicy = await getCommunityModerationPolicy();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const rows = await (await database.prepare(`SELECT users.id,
|
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_profiles profile ON profile.user_id=users.id
|
||||||
LEFT JOIN user_status status ON status.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
|
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 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)) {
|
if (unavailable(commenterState)) {
|
||||||
return fail(403, { profileCommentError: 'This account cannot post comments.' });
|
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
|
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||||
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
|
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
|
||||||
AND thread.target_type='profile' AND thread.target_id=?
|
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') {
|
if (typeof recent?.created_at === 'string') {
|
||||||
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
|
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
|
||||||
if (elapsed < RAPID_COMMENT_SECONDS) {
|
if (elapsed < RAPID_COMMENT_SECONDS) {
|
||||||
@@ -239,10 +264,10 @@ export const actions: Actions = {
|
|||||||
const proposedThreadId = randomUUID();
|
const proposedThreadId = randomUUID();
|
||||||
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
|
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
|
||||||
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
|
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
|
||||||
.run(proposedThreadId, params.id, createdAt);
|
.run(proposedThreadId, agentId, createdAt);
|
||||||
const thread = await (await database.prepare(
|
const thread = await (await database.prepare(
|
||||||
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
|
"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.' });
|
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
|
||||||
const commentId = randomUUID();
|
const commentId = randomUUID();
|
||||||
await (await database.prepare(`INSERT INTO comments
|
await (await database.prepare(`INSERT INTO comments
|
||||||
@@ -255,17 +280,18 @@ export const actions: Actions = {
|
|||||||
authorId: commenter.id,
|
authorId: commenter.id,
|
||||||
result: moderation
|
result: moderation
|
||||||
});
|
});
|
||||||
if (commentStatus === 'visible' && commenter.id !== params.id) {
|
if (commentStatus === 'visible' && commenter.id !== agentId) {
|
||||||
|
const profileLink = agentProfileHref(owner, 'profile-comments');
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: params.id,
|
userId: agentId,
|
||||||
category: 'comments',
|
category: 'comments',
|
||||||
source: 'agent',
|
source: 'agent',
|
||||||
title: 'New profile comment',
|
title: 'New profile comment',
|
||||||
body: `${commenter.displayName} posted a comment on your Agent profile.`,
|
body: `${commenter.displayName} posted a comment on your Agent profile.`,
|
||||||
link: `/agents/${encodeURIComponent(params.id)}#profile-comments`,
|
link: profileLink,
|
||||||
actions: [{
|
actions: [{
|
||||||
label: 'VIEW COMMENT',
|
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();
|
const commentId = String((await request.formData()).get('commentId') ?? '').trim();
|
||||||
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
|
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
|
||||||
const database = await getDatabase();
|
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
|
const comment = await (await database.prepare(`SELECT comments.id,comments.author_id
|
||||||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||||
WHERE comments.id=? AND comments.status='visible'
|
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) return fail(404, { profileCommentError: 'That comment is no longer available.' });
|
||||||
if (comment.author_id !== viewer.id) {
|
if (comment.author_id !== viewer.id) {
|
||||||
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
|
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.');
|
return failure(400, 'Report details may contain up to 500 visible characters.');
|
||||||
}
|
}
|
||||||
const database = await getDatabase();
|
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') {
|
if (contentType === 'comment') {
|
||||||
const comment = await (await database.prepare(`SELECT comments.author_id
|
const comment = await (await database.prepare(`SELECT comments.author_id
|
||||||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||||
WHERE comments.id=? AND comments.status='visible'
|
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 (!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.');
|
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
|
||||||
} else {
|
} else {
|
||||||
if (contentId !== params.id) return failure(400, 'That profile content does not match this Agent.');
|
if (contentId !== agentId) 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 (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,
|
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
|
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
|
||||||
FROM user_profiles profile
|
FROM user_profiles profile
|
||||||
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
||||||
LEFT JOIN user_avatars avatar ON avatar.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 (!profile?.is_public) return failure(404, 'That profile is no longer available.');
|
||||||
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
|
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
|
||||||
return failure(404, 'That biography is no longer available.');
|
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 (!report) return failure(409, 'You already have a pending report for this content.');
|
||||||
if (contentType === 'avatar') {
|
if (contentType === 'avatar') {
|
||||||
await queueCurrentAvatarReview(database, params.id, { origin: 'user_report' });
|
await queueCurrentAvatarReview(database, agentId, { origin: 'user_report' });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
userReportSaved: true,
|
userReportSaved: true,
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
|
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
|
||||||
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
|
||||||
|
|
||||||
type PublicAgent = {
|
type PublicAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
publicSlug: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
bioHtml: string;
|
bioHtml: string;
|
||||||
|
roles: string[];
|
||||||
};
|
};
|
||||||
type PublicContent = {
|
type PublicContent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,7 +35,7 @@
|
|||||||
id: string;
|
id: string;
|
||||||
bodyHtml: string;
|
bodyHtml: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: { id: string; displayName: string; avatarUrl: string };
|
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string };
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canReport: boolean;
|
canReport: boolean;
|
||||||
};
|
};
|
||||||
@@ -76,7 +79,10 @@
|
|||||||
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
|
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
|
||||||
|
|
||||||
<header class="agent-command">
|
<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">
|
<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.canReportProfile}<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>{/if}
|
||||||
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
|
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
|
||||||
@@ -221,7 +227,7 @@
|
|||||||
|
|
||||||
<style>
|
<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)}
|
.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{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-telemetry>div{display:grid;gap:4px;padding:10px 12px;border-left:1px solid var(--shd-border)}.agent-telemetry>div:first-child{border-left:0}.agent-telemetry span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.agent-telemetry strong{color:var(--shd-orange);font-size:24px}
|
||||||
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(330px,.38fr);gap:10px;margin-top:10px}.agent-primary{display:grid;gap:10px;min-width:0}
|
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(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 type { RequestHandler } from './$types';
|
||||||
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
||||||
import { getSiteAccessSettings } from '$lib/server/site-access';
|
import { getSiteAccessSettings } from '$lib/server/site-access';
|
||||||
|
import { insertUserProfile } from '$lib/server/agent-profiles';
|
||||||
|
|
||||||
const STATE_COOKIE = 'shd_discord_oauth_state';
|
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 (?, ?, ?, ?, ?)'
|
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
await insertUser.run(id, username, discordUser.id, 'user', now);
|
await insertUser.run(id, username, discordUser.id, 'user', now);
|
||||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
await insertUserProfile(
|
||||||
.run(id, discordUser.global_name || discordUser.username, now);
|
database,
|
||||||
|
id,
|
||||||
|
discordUser.global_name || discordUser.username,
|
||||||
|
now,
|
||||||
|
{ resolveCollision: true }
|
||||||
|
);
|
||||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||||
user = { id };
|
user = { id };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
const builds = await (await database.prepare(`
|
const builds = await (await database.prepare(`
|
||||||
SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
|
SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
|
||||||
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
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,
|
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.source AS avatar_source,user_avatars.value AS avatar_value,
|
||||||
user_avatars.updated_at AS avatar_updated_at,
|
user_avatars.updated_at AS avatar_updated_at,
|
||||||
@@ -26,6 +27,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
builds: builds.map((build) => ({
|
builds: builds.map((build) => ({
|
||||||
...build,
|
...build,
|
||||||
ownerId: String(build.owner_id),
|
ownerId: String(build.owner_id),
|
||||||
|
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||||
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||||
authorAvatarUrl: avatarUrl(
|
authorAvatarUrl: avatarUrl(
|
||||||
build.avatar_source,
|
build.avatar_source,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<article style="--build-color:#f6a821">
|
<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="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="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>
|
</article>
|
||||||
{:else}
|
{: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>
|
<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 viewer = await getSessionUser(cookies);
|
||||||
const build = await (await database.prepare(`
|
const build = await (await database.prepare(`
|
||||||
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
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,
|
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.source AS avatar_source,user_avatars.value AS avatar_value,
|
||||||
user_avatars.updated_at AS avatar_updated_at
|
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),
|
id: String(build.id), title: String(build.title), slug: String(build.slug),
|
||||||
visibility: String(build.visibility), author: String(build.author),
|
visibility: String(build.visibility), author: String(build.author),
|
||||||
ownerId: String(build.owner_id),
|
ownerId: String(build.owner_id),
|
||||||
|
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||||
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||||
authorAvatarUrl: avatarUrl(
|
authorAvatarUrl: avatarUrl(
|
||||||
build.avatar_source,
|
build.avatar_source,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
|
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
|
||||||
<main class="app-page shared-build-page">
|
<main class="app-page shared-build-page">
|
||||||
<header class="shared-build-command">
|
<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>
|
<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>
|
</header>
|
||||||
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
|
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { databaseCategory } from '$lib/database/categories';
|
import { databaseCategory } from '$lib/database/categories';
|
||||||
|
import type { DatabaseFact, DatabaseItem } from '$lib/database/items';
|
||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
import { weaponBaseRange } from '$lib/builder/loadout';
|
import { weaponBaseRange } from '$lib/builder/loadout';
|
||||||
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
|
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
|
||||||
@@ -33,6 +34,54 @@ const humanized = (value: unknown) => String(value ?? '')
|
|||||||
.replaceAll('_', ' ')
|
.replaceAll('_', ' ')
|
||||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
.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 }) => {
|
export const load: PageServerLoad = async ({ params, url }) => {
|
||||||
const category = databaseCategory(params.category);
|
const category = databaseCategory(params.category);
|
||||||
if (!category) error(404, 'Database category not found');
|
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
|
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))
|
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,
|
? 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.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
|
||||||
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
|
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 conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
|
||||||
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
|
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
|
||||||
WHERE rules.source_type='talent' AND rules.configured=1
|
WHERE rules.source_type='talent' AND rules.configured=1
|
||||||
AND rules.source_id IN (${rows.map(() => '?').join(',')})
|
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
|
||||||
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...rows.map((row) => row.id))
|
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) => {
|
const items = rows.map((row) => {
|
||||||
switch (category.slug) {
|
switch (category.slug) {
|
||||||
@@ -383,21 +442,10 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
|||||||
label: `${effect.pieces_required} PIECES`,
|
label: `${effect.pieces_required} PIECES`,
|
||||||
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
|
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
|
||||||
}))
|
}))
|
||||||
} : {
|
} : talentCardItem({
|
||||||
id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`,
|
...relation,
|
||||||
iconUrl: relationIcon,
|
icon_updated_at: relation.updated_at
|
||||||
badges: [
|
}, mechanicsFor(relation.id))
|
||||||
...(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.'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const iconGroups = [
|
const iconGroups = [
|
||||||
@@ -428,21 +476,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
|||||||
id: String(relation.id),
|
id: String(relation.id),
|
||||||
name: String(relation.name),
|
name: String(relation.name),
|
||||||
iconUrl: talentIcon,
|
iconUrl: talentIcon,
|
||||||
preview: {
|
preview: talentCardItem(relation, mechanicsFor(relation.id), slot)
|
||||||
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.'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const level50Damage = row.damage_min === null ? null : weaponBaseRange(row.damage_min, row.damage_max, 50);
|
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') }]
|
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
|
||||||
};
|
};
|
||||||
case 'talents': {
|
case 'talents':
|
||||||
const talentCategory = String(row.category);
|
return talentCardItem(row, mechanicsFor(row.id));
|
||||||
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 'brands': {
|
case 'brands': {
|
||||||
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
|
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p
|
|||||||
import { error, fail } from '@sveltejs/kit';
|
import { error, fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { agentProfileHref } from '$lib/agents';
|
||||||
|
|
||||||
const COMMENT_PERMISSION = 'community.comments.moderate';
|
const COMMENT_PERMISSION = 'community.comments.moderate';
|
||||||
const PROFILE_PERMISSION = 'community.profiles.moderate';
|
const PROFILE_PERMISSION = 'community.profiles.moderate';
|
||||||
@@ -27,9 +28,12 @@ const requiredReason = (value: FormDataEntryValue | null) => {
|
|||||||
return reason.length >= 3 ? reason : 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'
|
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`;
|
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
|
||||||
|
|
||||||
const parseAuditSnapshot = (value: unknown) => {
|
const parseAuditSnapshot = (value: unknown) => {
|
||||||
@@ -77,15 +81,21 @@ const queueTargetHref = (
|
|||||||
contentId: unknown,
|
contentId: unknown,
|
||||||
targetType: unknown,
|
targetType: unknown,
|
||||||
targetId: 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') {
|
if (contentType === 'guide') {
|
||||||
return slug
|
return slug
|
||||||
? `/builds/${encodeURIComponent(String(slug))}`
|
? `/builds/${encodeURIComponent(String(slug))}`
|
||||||
: `/builds/${encodeURIComponent(String(contentId))}`;
|
: `/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, {
|
const queueActionFailure = (status: number, message: string) => fail(status, {
|
||||||
@@ -235,6 +245,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const scanQueue = `SELECT
|
const scanQueue = `SELECT
|
||||||
scan.id AS queue_id,'scan' AS item_source,scan.content_type,scan.content_id,
|
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,
|
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.source AS avatar_source,author_avatar.value AS avatar_value,
|
||||||
author_avatar.updated_at AS avatar_updated_at,
|
author_avatar.updated_at AS avatar_updated_at,
|
||||||
COALESCE(revision.body_markdown,comments.body_markdown,target_build.guide_body,'') AS body_markdown,
|
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')
|
THEN COALESCE(target_build.title,'Build guide')
|
||||||
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
||||||
END AS target_title,
|
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
|
FROM content_moderation_scans scan
|
||||||
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
|
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
|
||||||
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_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,
|
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(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,
|
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.source AS avatar_source,author_avatar.value AS avatar_value,
|
||||||
author_avatar.updated_at AS avatar_updated_at,
|
author_avatar.updated_at AS avatar_updated_at,
|
||||||
COALESCE(comments.body_markdown,profile.bio_markdown,target_build.guide_body,'') AS body_markdown,
|
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')
|
THEN COALESCE(target_build.title,'Build guide')
|
||||||
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
|
||||||
END AS target_title,
|
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
|
FROM user_content_reports report
|
||||||
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
|
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
|
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.content_id,
|
||||||
row.target_type,
|
row.target_type,
|
||||||
row.target_id,
|
row.target_id,
|
||||||
row.slug
|
row.slug,
|
||||||
|
row.target_public_slug
|
||||||
),
|
),
|
||||||
author: {
|
author: {
|
||||||
id: String(row.author_id ?? ''),
|
id: String(row.author_id ?? ''),
|
||||||
displayName: String(row.display_name),
|
displayName: String(row.display_name),
|
||||||
|
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
|
||||||
avatarUrl: avatarUrl(
|
avatarUrl: avatarUrl(
|
||||||
row.avatar_source,
|
row.avatar_source,
|
||||||
row.avatar_value,
|
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,
|
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
|
||||||
comments.created_at,comments.updated_at,comments.removal_reason,
|
comments.created_at,comments.updated_at,comments.removal_reason,
|
||||||
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
|
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,
|
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,
|
thread.target_type,thread.target_id,builds.slug,
|
||||||
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
|
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,
|
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
|
||||||
targetType: String(row.target_type),
|
targetType: String(row.target_type),
|
||||||
targetTitle: String(row.target_title),
|
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: {
|
author: {
|
||||||
id: String(row.author_id),
|
id: String(row.author_id),
|
||||||
displayName: String(row.display_name),
|
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)
|
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
|
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
|
||||||
${where}`)).get(...args))?.count ?? 0);
|
${where}`)).get(...args))?.count ?? 0);
|
||||||
const pagination = getPagination(url, total);
|
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,
|
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,
|
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
|
||||||
moderation.reason AS moderation_reason
|
moderation.reason AS moderation_reason
|
||||||
@@ -545,6 +561,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
agent: {
|
agent: {
|
||||||
id: String(row.user_id),
|
id: String(row.user_id),
|
||||||
displayName: String(row.display_name),
|
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)
|
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 PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.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 = {
|
type CommunityComment = {
|
||||||
id: string;
|
id: string;
|
||||||
bodyHtml: string;
|
bodyHtml: string;
|
||||||
@@ -279,7 +281,7 @@
|
|||||||
<a href={item.targetHref}>{item.targetTitle} →</a>
|
<a href={item.targetHref}>{item.targetTitle} →</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
<div class="community-markdown-preview">
|
<div class="community-markdown-preview">
|
||||||
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
|
<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 { recordIsacAudit } from '$lib/server/audit';
|
||||||
import {
|
import {
|
||||||
MANAGED_IMAGE_CATEGORIES,
|
MANAGED_IMAGE_CATEGORIES,
|
||||||
|
ManagedImageDeletionError,
|
||||||
ManagedImageValidationError,
|
ManagedImageValidationError,
|
||||||
|
deleteManagedImage,
|
||||||
managedImageUrl,
|
managedImageUrl,
|
||||||
processManagedImage,
|
processManagedImage,
|
||||||
saveManagedImageFile
|
saveManagedImageFile
|
||||||
@@ -124,5 +126,25 @@ export const actions: Actions = {
|
|||||||
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||||
throw error;
|
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">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
@@ -8,16 +9,35 @@
|
|||||||
let isIcon = $state(true);
|
let isIcon = $state(true);
|
||||||
let resetToken = $state(0);
|
let resetToken = $state(0);
|
||||||
let consumedToken = $state<string | null>(null);
|
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 size = (bytes: unknown) => {
|
||||||
const value = Number(bytes ?? 0);
|
const value = Number(bytes ?? 0);
|
||||||
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
};
|
};
|
||||||
const sourceLabel = (value: unknown) => String(value ?? '').replaceAll('_', ' ').toLocaleUpperCase();
|
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(() => {
|
$effect(() => {
|
||||||
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
|
if (form?.resultToken && form.resultToken !== consumedToken) {
|
||||||
consumedToken = form.resultToken;
|
consumedToken = form.resultToken;
|
||||||
if (form.editorMode === 'edit') editing = null;
|
if (form.imageDeleted) {
|
||||||
else {
|
deletionTarget = null;
|
||||||
|
deletionTrigger = null;
|
||||||
|
} else if (form.imageSaved && form.editorMode === 'edit') editing = null;
|
||||||
|
else if (form.imageSaved) {
|
||||||
adding = true;
|
adding = true;
|
||||||
isIcon = true;
|
isIcon = true;
|
||||||
resetToken += 1;
|
resetToken += 1;
|
||||||
@@ -26,6 +46,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && deletionTarget) void closeDeletion(); }}/>
|
||||||
|
|
||||||
<svelte:head><title>Image library — ISAC</title></svelte:head>
|
<svelte:head><title>Image library — ISAC</title></svelte:head>
|
||||||
<div class="module-toolbar">
|
<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>
|
<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">
|
<section class="image-library-grid" aria-label="Managed image library">
|
||||||
{#each data.images as image}
|
{#each data.images as image}
|
||||||
<article class:icon={image.is_icon}>
|
<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>
|
<header>
|
||||||
<div><strong>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
|
<div><strong title={image.name}>{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>
|
<b class:assigned={image.source_entity_type}>{image.source_entity_type ? 'ASSIGNED' : image.is_icon ? 'ICON' : 'IMAGE'}</b>
|
||||||
</header>
|
</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>
|
<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>
|
</article>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="image-library-empty"><strong>NO MATCHING ASSETS</strong><span>Upload the first image or revise the registry search.</span></div>
|
<div class="image-library-empty"><strong>NO MATCHING ASSETS</strong><span>Upload the first image or revise the registry search.</span></div>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</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';
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
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 }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||||
@@ -68,7 +79,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
roles: roles.map((role) => ({
|
roles: roles.map((role) => ({
|
||||||
...role,
|
...role,
|
||||||
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
|
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,
|
permissions,
|
||||||
summary,
|
summary,
|
||||||
@@ -95,7 +110,9 @@ export const actions: Actions = {
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
|
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 && !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')) {
|
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.' });
|
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) }
|
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
|
||||||
});
|
});
|
||||||
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
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 { data, form } = $props();
|
||||||
let editing = $state<any>(null);
|
let editing = $state<any>(null);
|
||||||
|
let deleting = $state<any>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
const blank = () => ({ id: '', name: '', description: '', permissionIds: [] });
|
const blank = () => ({ id: '', name: '', description: '', permissionIds: [] });
|
||||||
const permissionName = (id: string) => data.permissions.find((permission) => permission.id === id)?.name ?? id;
|
const permissionName = (id: string) => data.permissions.find((permission) => permission.id === id)?.name ?? id;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!form?.roleSaved || !form.resultToken || form.resultToken === handledResultToken) return;
|
if ((!form?.roleSaved && !form?.roleDeleted) || !form.resultToken || form.resultToken === handledResultToken) return;
|
||||||
handledResultToken = form.resultToken;
|
handledResultToken = form.resultToken;
|
||||||
|
if (form.roleDeleted) {
|
||||||
|
deleting = null;
|
||||||
|
editing = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
editing = form.roleMode === 'add' ? blank() : null;
|
editing = form.roleMode === 'add' ? blank() : null;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -54,7 +60,37 @@
|
|||||||
<div class="dashboard-table">
|
<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>
|
<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}
|
{#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}
|
{#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}
|
{: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>
|
</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">
|
<script lang="ts">
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
|
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
|
||||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
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"/>
|
<RegistryPagination pagination={data.pagination} search={data.query} searchPlaceholder="SEARCH DISPLAY OR LOGIN NAME"/>
|
||||||
<div class="dashboard-table">
|
<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>
|
<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}
|
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
||||||
import { getSiteAccessSettings } from '$lib/server/site-access';
|
import { getSiteAccessSettings } from '$lib/server/site-access';
|
||||||
|
import {
|
||||||
|
displayNameIsAvailable,
|
||||||
|
insertUserProfile
|
||||||
|
} from '$lib/server/agent-profiles';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const { accountCreationEnabled } = await getSiteAccessSettings();
|
const { accountCreationEnabled } = await getSiteAccessSettings();
|
||||||
@@ -55,8 +59,7 @@ export const actions: Actions = {
|
|||||||
const id = randomUUID(), now = new Date().toISOString();
|
const id = randomUUID(), now = new Date().toISOString();
|
||||||
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
|
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
|
||||||
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
|
.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(?,?,?)'))
|
await insertUserProfile(database, id, 'Agent', now, { placeholder: true });
|
||||||
.run(id, 'New Agent', now);
|
|
||||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||||
identity = { user_id: id, created_at: null };
|
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)) {
|
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 });
|
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();
|
const id = randomUUID(), now = new Date().toISOString();
|
||||||
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
|
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);
|
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(?,?,?)'))
|
await insertUserProfile(database, id, displayName, now);
|
||||||
.run(id, displayName, now);
|
|
||||||
await ensureBootstrapAdmin(database, id);
|
await ensureBootstrapAdmin(database, id);
|
||||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||||
|
|||||||
@@ -111,11 +111,13 @@
|
|||||||
Private passkey material remains on your authenticator.
|
Private passkey material remains on your authenticator.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
A public Agent profile displays your public display name, avatar, biography, public
|
A public Agent profile displays your public display name, avatar, assigned site-role
|
||||||
builds, published guides, recent public discussion, and profile messages. Other
|
names, biography, public builds, published guides, recent public discussion, and
|
||||||
signed-in users may post messages on a public profile when their account has
|
profile messages. SHD.build does not display role permissions, descriptions, or
|
||||||
permission to do so. Profile messages display the author's public identity and may
|
direct permission grants on public profiles. Other signed-in users may post messages
|
||||||
be removed by their author or Community Oversight.
|
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>
|
||||||
<p>
|
<p>
|
||||||
You may hide your Agent profile from public discovery and prevent new profile
|
You may hide your Agent profile from public discovery and prevent new profile
|
||||||
|
|||||||
Reference in New Issue
Block a user