Add public agent profiles and community moderation

This commit is contained in:
JB
2026-07-27 15:38:27 -04:00
parent efb0ccdc7e
commit 6b76110b76
28 changed files with 2101 additions and 64 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
<script lang="ts">
let {
agent,
size = 'default',
meta = '',
showId = false,
linked = true
}: {
agent: { id: string; displayName: string; avatarUrl: string };
size?: 'compact' | 'default' | 'large';
meta?: string;
showId?: boolean;
linked?: boolean;
} = $props();
</script>
{#if linked}
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={`/agents/${encodeURIComponent(agent.id)}`}>
<img src={agent.avatarUrl} alt="" />
<span>
<strong>{agent.displayName}</strong>
{#if meta}<small>{meta}</small>{:else if showId}<small>AGENT {agent.id.slice(0,8).toUpperCase()}</small>{/if}
</span>
<i aria-hidden="true">›</i>
</a>
{:else}
<div class="agent-identity unavailable" class:compact={size === 'compact'} class:large={size === 'large'}>
<img src={agent.avatarUrl} alt="" />
<span><strong>{agent.displayName}</strong><small>PROFILE HIDDEN</small></span>
</div>
{/if}
<style>
.agent-identity{min-width:0;display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:9px;color:var(--shd-text)}
.agent-identity img{width:38px;height:38px;border:1px solid var(--shd-orange);border-radius:50%;background:var(--shd-surface);object-fit:cover}
.agent-identity span{min-width:0;display:grid;gap:3px}.agent-identity strong,.agent-identity small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.agent-identity strong{font-size:var(--shd-type-supporting)}.agent-identity small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.agent-identity i{color:var(--shd-text-dim);font-size:18px;font-style:normal}.agent-identity:hover strong,.agent-identity:focus-visible strong,.agent-identity:hover i,.agent-identity:focus-visible i{color:var(--shd-orange)}
.agent-identity.compact{grid-template-columns:30px minmax(0,1fr) auto;gap:7px}.agent-identity.compact img{width:30px;height:30px}.agent-identity.compact strong{font-size:var(--shd-type-telemetry)}
.agent-identity.large{grid-template-columns:74px minmax(0,1fr) auto;gap:15px}.agent-identity.large img{width:74px;height:74px;box-shadow:0 0 0 5px var(--shd-bg),0 0 0 6px color-mix(in srgb,var(--shd-orange) 35%,transparent)}.agent-identity.large strong{font-size:clamp(1.5rem,3vw,2.6rem);line-height:1}.agent-identity.large small{font-size:var(--shd-type-supporting)}
.agent-identity.unavailable{grid-template-columns:38px minmax(0,1fr)}.agent-identity.unavailable strong{color:var(--shd-text-muted)}
.agent-identity.unavailable.compact{grid-template-columns:30px minmax(0,1fr)}.agent-identity.unavailable.large{grid-template-columns:74px minmax(0,1fr)}
@media(max-width:600px){.agent-identity{min-height:44px}.agent-identity.large{grid-template-columns:58px minmax(0,1fr) auto}.agent-identity.large img{width:58px;height:58px}.agent-identity.large strong{font-size:1.6rem}.agent-identity.unavailable.large{grid-template-columns:58px minmax(0,1fr)}}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
let {
html = '',
emptyMessage = 'No field notes have been added.',
compact = false
}: {
html?: string;
emptyMessage?: string;
compact?: boolean;
} = $props();
</script>
{#if html.trim()}
<div class="markdown-content" class:compact>{@html html}</div>
{:else}
<p class="markdown-empty">{emptyMessage}</p>
{/if}
<style>
.markdown-content{max-width:76ch;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);line-height:1.7;overflow-wrap:anywhere}
.markdown-content.compact{font-size:var(--shd-type-supporting);line-height:1.55}
.markdown-content :global(h3),.markdown-content :global(h4),.markdown-content :global(h5){margin:1.5rem 0 .45rem;color:var(--shd-text);font-family:var(--shd-font-sans);line-height:1.15}
.markdown-content :global(h3){font-size:1.55rem}.markdown-content :global(h4){font-size:1.3rem}.markdown-content :global(h5){font-size:1.1rem}
.markdown-content :global(p),.markdown-content :global(ul),.markdown-content :global(ol),.markdown-content :global(blockquote){margin:.7em 0}
.markdown-content :global(h3 + p),.markdown-content :global(h4 + p),.markdown-content :global(h5 + p),.markdown-content :global(h3 + ul),.markdown-content :global(h4 + ul),.markdown-content :global(h5 + ul),.markdown-content :global(h3 + ol),.markdown-content :global(h4 + ol),.markdown-content :global(h5 + ol),.markdown-content :global(h3 + blockquote),.markdown-content :global(h4 + blockquote),.markdown-content :global(h5 + blockquote){margin-top:0}
.markdown-content :global(ul),.markdown-content :global(ol){padding-left:1.4rem}
.markdown-content :global(li+li){margin-top:.3em}
.markdown-content :global(a){color:var(--shd-orange);font-family:var(--shd-font-sans);font-weight:700}
.markdown-content :global(a:hover),.markdown-content :global(a:focus-visible){color:var(--shd-orange-hover)}
.markdown-content :global(blockquote){padding:.2em 0 .2em 1em;border-left:2px solid var(--shd-orange);color:var(--shd-text-muted)}
.markdown-content :global(code){padding:2px 4px;border:1px solid var(--shd-border);background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-mono);font-size:.9em}
.markdown-content :global(:first-child){margin-top:0}
.markdown-content :global(:last-child){margin-bottom:0}
.markdown-empty{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.55}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import { renderUserMarkdown } from '$lib/markdown';
import { tick } from 'svelte';
let {
value = $bindable(''),
name,
label = 'MARKDOWN',
help = 'Use Markdown for headings, lists, emphasis, quotes, and links.',
maxLength = 4000,
rows = 9,
required = false,
disabled = false,
id = `${name}-markdown-editor`
}: {
value?: string;
name: string;
label?: string;
help?: string;
maxLength?: number;
rows?: number;
required?: boolean;
disabled?: boolean;
id?: string;
} = $props();
let textarea = $state<HTMLTextAreaElement>();
let previewing = $state(false);
const remaining = $derived(maxLength - value.length);
const previewHtml = $derived(renderUserMarkdown(value));
async function replaceSelection(
before: string,
after = '',
placeholder = 'text',
linePrefix = false
) {
if (!textarea || disabled) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (linePrefix) {
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
value = `${value.slice(0, lineStart)}${before}${value.slice(lineStart)}`.slice(0, maxLength);
await tick();
textarea.focus();
textarea.setSelectionRange(start + before.length, end + before.length);
return;
}
const selected = value.slice(start, end) || placeholder;
const insert = `${before}${selected}${after}`;
value = `${value.slice(0, start)}${insert}${value.slice(end)}`.slice(0, maxLength);
await tick();
textarea.focus();
const selectionStart = start + before.length;
textarea.setSelectionRange(selectionStart, Math.min(selectionStart + selected.length, value.length));
}
</script>
<div class="markdown-editor">
<div class="markdown-editor-head">
<label for={id}>{label}</label>
<div class="markdown-editor-meta">
<div class="markdown-view-tabs" aria-label={`${label} view`}>
<button type="button" class:active={!previewing} aria-pressed={!previewing} onclick={() => previewing = false}>EDIT</button>
<button type="button" class:active={previewing} aria-pressed={previewing} onclick={() => previewing = true}>PREVIEW</button>
</div>
<span class:near-limit={remaining < Math.min(200, maxLength * .1)}>{remaining.toLocaleString()} REMAINING</span>
</div>
</div>
{#if previewing}
<input type="hidden" {name} value={value}/>
<div class="markdown-preview" aria-live="polite"><MarkdownContent html={previewHtml} emptyMessage="Nothing to preview yet."/></div>
{:else}
<div class="markdown-toolbar" aria-label={`${label} formatting controls`}>
<button type="button" disabled={disabled} aria-label="Bold selected text" onclick={() => replaceSelection('**','**','bold text')}><b>B</b></button>
<button type="button" disabled={disabled} aria-label="Italicize selected text" onclick={() => replaceSelection('*','*','italic text')}><i>I</i></button>
<button type="button" disabled={disabled} aria-label="Add heading" onclick={() => replaceSelection('## ','','Heading',true)}>H2</button>
<button type="button" disabled={disabled} aria-label="Add bulleted list item" onclick={() => replaceSelection('- ','','List item',true)}>LIST</button>
<button type="button" disabled={disabled} aria-label="Add quote" onclick={() => replaceSelection('> ','','Quoted text',true)}>QUOTE</button>
<button type="button" disabled={disabled} aria-label="Add link" onclick={() => replaceSelection('[','](https://)','link text')}>LINK</button>
</div>
<textarea
bind:this={textarea}
bind:value
{id}
{name}
{rows}
{required}
{disabled}
maxlength={maxLength}
spellcheck="true"
></textarea>
{/if}
<div class="markdown-editor-foot">
<small>{help}</small>
<span>MARKDOWN SUPPORTED</span>
</div>
</div>
<style>
.markdown-editor{display:grid;border:1px solid var(--shd-border);background:#0d0f10}
.markdown-editor-head,.markdown-editor-foot{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;background:var(--shd-surface-raised)}
.markdown-editor-head{border-bottom:1px solid var(--shd-border)}.markdown-editor-foot{border-top:1px solid var(--shd-border)}
.markdown-editor-head label,.markdown-editor-head span,.markdown-editor-foot span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.markdown-editor-head label{color:var(--shd-text)}.markdown-editor-head span,.markdown-editor-foot span{color:var(--shd-text-muted)}.markdown-editor-head span.near-limit{color:var(--shd-orange)}
.markdown-editor-meta{display:flex;align-items:center;gap:9px}.markdown-view-tabs{display:flex;border:1px solid var(--shd-border)}.markdown-view-tabs button{min-height:28px;padding:0 8px;border:0;border-left:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.markdown-view-tabs button:first-child{border-left:0}.markdown-view-tabs button.active{background:#211a0e;color:var(--shd-orange)}
.markdown-editor-foot small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.markdown-toolbar{display:flex;flex-wrap:wrap;border-bottom:1px solid var(--shd-border)}
.markdown-toolbar button{min-width:40px;height:36px;padding:0 9px;border:0;border-right:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.markdown-toolbar button:hover,.markdown-toolbar button:focus-visible{background:var(--shd-surface-active);color:var(--shd-orange)}
.markdown-toolbar button:active{transform:translateY(1px);filter:brightness(.82)}
.markdown-toolbar button:disabled{cursor:not-allowed;opacity:.48}
.markdown-editor textarea{width:100%;min-height:150px;margin:0;padding:11px;border:0;border-radius:0;background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);line-height:1.55;resize:vertical}
.markdown-editor textarea:focus-visible{position:relative;z-index:1;outline:1px solid var(--shd-orange);outline-offset:-1px}
.markdown-preview{min-height:150px;padding:11px}.markdown-view-tabs button:active{transform:translateY(1px)}
@media(max-width:600px){.markdown-toolbar button{min-width:44px;height:44px}.markdown-editor-head,.markdown-editor-foot{align-items:flex-start;flex-direction:column}.markdown-editor-meta{width:100%;justify-content:space-between}.markdown-view-tabs button{min-height:44px}.markdown-editor-foot span{align-self:flex-end}}
@media(prefers-reduced-motion:reduce){.markdown-toolbar button{transition:none}}
</style>
+114
View File
@@ -0,0 +1,114 @@
const escapeHtml = (value: string) => value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
function safeLink(raw: string) {
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
} catch {
return null;
}
}
function inlineMarkdown(value: string) {
const code: string[] = [];
let output = escapeHtml(value).replace(/`([^`\r\n]+)`/g, (_match, body: string) => {
const token = `\u0000CODE${code.length}\u0000`;
code.push(`<code>${body}</code>`);
return token;
});
output = output.replace(/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g, (_match, label: string, href: string) => {
const decodedHref = href
.replaceAll('&amp;', '&')
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"');
const safe = safeLink(decodedHref);
return safe
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
: label;
});
output = output
.replace(/\*\*([^*\r\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_\r\n]+)__/g, '<strong>$1</strong>')
.replace(/(^|[^\w])\*([^*\r\n]+)\*/g, '$1<em>$2</em>')
.replace(/(^|[^\w])_([^_\r\n]+)_/g, '$1<em>$2</em>');
return output.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? '');
}
/**
* Renders the shared Markdown subset accepted for user biographies and comments.
* Raw HTML is always escaped. Links are restricted to HTTP(S) and receive UGC,
* nofollow, and opener isolation attributes.
*/
export function renderUserMarkdown(markdown: unknown): string {
const input = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
if (!input) return '';
const lines = input.split('\n');
const output: string[] = [];
let paragraph: string[] = [];
let list: 'ul' | 'ol' | null = null;
const closeParagraph = () => {
if (!paragraph.length) return;
output.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
output.push(`</${list}>`);
list = null;
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
closeParagraph();
closeList();
continue;
}
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
if (heading) {
closeParagraph();
closeList();
const level = heading[1].length + 2;
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const unordered = /^[-*]\s+(.+)$/.exec(trimmed);
const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
if (unordered || ordered) {
closeParagraph();
const nextList = unordered ? 'ul' : 'ol';
if (list !== nextList) {
closeList();
list = nextList;
output.push(`<${list}>`);
}
output.push(`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`);
continue;
}
const quote = /^>\s?(.+)$/.exec(trimmed);
if (quote) {
closeParagraph();
closeList();
output.push(`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`);
continue;
}
closeList();
paragraph.push(trimmed);
}
closeParagraph();
closeList();
return output.join('');
}
export function normalizeUserMarkdown(markdown: unknown, maxCharacters: number) {
const normalized = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
if (Array.from(normalized).length > maxCharacters) return null;
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized)) return null;
return normalized;
}
+9 -3
View File
@@ -26,9 +26,15 @@ export async function softDeleteAccount(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
)).run(deletedUsername, userId);
await (await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`
)).run(userId, 'Deleted Agent', now);
`INSERT INTO user_profiles(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,0,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
bio_markdown='',
is_public=0,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`
)).run(userId, 'Deleted Agent', '', now, now);
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { normalizeUserMarkdown, renderUserMarkdown } from '../markdown';
describe('user Markdown', () => {
it('escapes raw HTML and blocks executable links', () => {
const rendered = renderUserMarkdown(
'<script>alert(1)</script> [unsafe](javascript:alert(1)) [safe](https://shd.build)'
);
expect(rendered).toContain('&lt;script&gt;');
expect(rendered).not.toContain('<script>');
expect(rendered).not.toContain('javascript:');
expect(rendered).toContain('href="https://shd.build/"');
});
it('renders the supported biography formatting subset', () => {
expect(renderUserMarkdown('## Agent\n\n- **Medic**\n- _Support_')).toBe(
'<h4>Agent</h4><ul><li><strong>Medic</strong></li><li><em>Support</em></li></ul>'
);
});
it('bounds Unicode characters and rejects control characters', () => {
expect(normalizeUserMarkdown('漢字', 2)).toBe('漢字');
expect(normalizeUserMarkdown('漢字A', 2)).toBeNull();
expect(normalizeUserMarkdown('hello\u0000world', 20)).toBeNull();
});
});
+77 -3
View File
@@ -9,12 +9,14 @@ import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import { recordIsacAudit } from '$lib/server/audit';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { randomUUID } from 'node:crypto';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] = await Promise.all([
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
@@ -31,7 +33,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
ORDER BY n.created_at DESC LIMIT 5`)).all(user.id),
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id)
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,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=?`)).get(user.id)
]);
const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -55,6 +62,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}
: null;
return {
user: {
...user,
bioMarkdown: String(profile?.bio_markdown ?? ''),
profileIsPublic: profile?.is_public === undefined ? true : Boolean(profile.is_public)
},
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
discordConnection: connection?.discord_id ? {
id: String(connection.discord_id),
@@ -77,11 +89,73 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
chooseDisplayName: url.searchParams.has('choose_display_name'),
discordLinked: url.searchParams.has('discord_linked'),
oauthError,
avatarPresets: AVATAR_PRESETS
avatarPresets: AVATAR_PRESETS,
profile: {
bioMarkdown: String(profile?.bio_markdown ?? ''),
bioHtml: renderUserMarkdown(profile?.bio_markdown),
isPublic: Boolean(profile?.is_public),
bioHidden: Boolean(profile?.bio_hidden),
moderationReason: profile?.moderation_reason ? String(profile.moderation_reason) : null,
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
href: `/agents/${encodeURIComponent(user.id)}`
}
};
};
export const actions: Actions = {
saveBio: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const bio = normalizeUserMarkdown(form.get('bio'), 4_000);
if (bio === null) {
return fail(400, {
bioError: 'Agent biographies may contain up to 4,000 characters and cannot contain control characters.',
bioResultToken: randomUUID()
});
}
const now = new Date().toISOString();
const database = await getDatabase();
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,1,?,?)
ON CONFLICT(user_id) DO UPDATE SET
bio_markdown=excluded.bio_markdown,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`))
.run(user.id, user.displayName, bio, now, now);
return {
bioSaved: true,
bioMarkdown: bio,
bioResultToken: randomUUID()
};
},
setProfileVisibility: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const isPublicValue = String((await request.formData()).get('isPublic') ?? '');
if (!['0', '1'].includes(isPublicValue)) {
return fail(400, {
profileVisibilityError: 'Choose whether your Agent profile is public.',
profileVisibilityResultToken: randomUUID()
});
}
const isPublic = isPublicValue === '1';
const now = new Date().toISOString();
const database = await getDatabase();
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,is_public,updated_at)
VALUES(?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
is_public=excluded.is_public,
updated_at=excluded.updated_at`))
.run(user.id, user.displayName, isPublic ? 1 : 0, now);
return {
profileVisibilitySaved: true,
profileIsPublic: isPublic,
profileVisibilityResultToken: randomUUID()
};
},
requestEmailVerification: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
+65 -1
View File
@@ -2,6 +2,7 @@
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import { toast } from '$lib/toast';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
@@ -47,6 +48,24 @@
let passkeyMessage = $state('');
let passkeyClientError = $state('');
let passkeyName = $state('My passkey');
let bioDraft = $state('');
let bioInitialized = $state(false);
let handledBioToken = $state('');
$effect(() => {
const token = String(form?.bioResultToken ?? '');
if (!bioInitialized) {
bioDraft = String(user.bioMarkdown ?? '');
bioInitialized = true;
}
if (!token || token === handledBioToken) return;
handledBioToken = token;
if (form?.bioSaved) {
bioDraft = String(user.bioMarkdown ?? bioDraft);
toast.success('Profile bio saved', 'Your public Agent profile has been updated.');
} else if (form?.bioError) {
toast.error('Profile bio not saved', String(form.bioError));
}
});
onMount(() => passkeySupported = browserSupportsWebAuthn());
async function registerPasskey() {
if (passkeyPending) return;
@@ -82,7 +101,7 @@
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></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={`/agents/${encodeURIComponent(user.id)}`}>VIEW PUBLIC PROFILE →</a></div></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
@@ -143,6 +162,44 @@
<hr/>
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
<hr/>
<p class="eyebrow">FIELD DOSSIER</p><h2>PUBLIC BIO</h2>
{#if data.profile.bioHidden}
<div class="account-alert error-alert" role="status"><b>BIO HIDDEN BY OVERSIGHT</b><span>{data.profile.moderationReason ?? 'Review the ISAC notification in your inbox for details.'} You may edit your bio, but it will remain hidden until Oversight restores it.</span></div>
{/if}
<PendingForm action="?/saveBio">
<MarkdownEditor
name="bio"
label="AGENT BIO"
help="Describe your playstyle, preferred roles, community work, or current objectives. Links are allowed."
maxLength={4000}
rows={10}
bind:value={bioDraft}
/>
{#if form?.bioError}<p class="error">{form.bioError}</p>{/if}
{#if form?.bioSaved}<p class="success">Public bio updated.</p>{/if}
<button class="primary console-action">SAVE PUBLIC BIO →</button>
</PendingForm>
<div class="profile-visibility-control">
<div>
<span>PROFILE VISIBILITY</span>
<strong>{user.profileIsPublic !== false ? 'PUBLIC' : 'HIDDEN'}</strong>
<p>
{user.profileIsPublic !== false
? 'Other agents can discover this profile and leave profile messages.'
: 'This profile is excluded from discovery and cannot receive new profile messages.'}
</p>
<small>Hiding your profile does not remove your name or avatar from public builds, guides, or comments you authored.</small>
</div>
<PendingForm action="?/setProfileVisibility">
<input type="hidden" name="isPublic" value={user.profileIsPublic === false ? '1' : '0'}/>
<button class={user.profileIsPublic === false ? 'primary' : 'secondary'}>
{user.profileIsPublic === false ? 'MAKE PROFILE PUBLIC →' : 'HIDE PUBLIC PROFILE'}
</button>
</PendingForm>
</div>
{#if form?.profileVisibilityError}<p class="error">{form.profileVisibilityError}</p>{/if}
{#if form?.profileVisibilitySaved}<p class="success">Profile visibility updated.</p>{/if}
</div>
{:else if accountPanel === 'access'}
<div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab">
@@ -226,4 +283,11 @@
.profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; }
.profile-notification-list a.positive em { color: var(--shd-success); }
.profile-notification-list a.negative em { color: var(--shd-danger); }
.profile-public-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.profile-public-actions a{min-height:28px;display:inline-flex;align-items:center;padding:4px 7px;border:1px solid var(--shd-border);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.profile-public-actions a:hover,.profile-public-actions a:focus-visible{border-color:var(--shd-orange);background:#211a0e}
.profile-visibility-control{display:grid;gap:9px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:#0d1011}
.profile-visibility-control>div{display:grid;gap:4px}.profile-visibility-control span,.profile-visibility-control strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.profile-visibility-control span{color:var(--shd-text-muted)}.profile-visibility-control strong{color:var(--shd-info)}.profile-visibility-control p,.profile-visibility-control small{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.profile-visibility-control small{font-family:var(--shd-font-editorial)}
.profile-visibility-control :global(form button){width:100%;min-height:40px}
@media(max-width:600px){.profile-public-actions a,.profile-visibility-control :global(form button){min-height:44px}}
</style>
+282
View File
@@ -0,0 +1,282 @@
import { avatarUrl } from '$lib/avatar';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { createNotification } from '$lib/server/notifications';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
const RAPID_COMMENT_SECONDS = 10;
const DUPLICATE_COMMENT_SECONDS = 300;
const unavailable = (row: Record<string, unknown> | null | undefined) => {
const banned = typeof row?.banned_until === 'string' && new Date(row.banned_until) > new Date();
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
};
const commentHref = (targetType: string, slug: unknown, targetId: string) => {
if (targetType === 'profile') return `/agents/${encodeURIComponent(targetId)}#profile-comments`;
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
};
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const agent = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,
profile.bio_markdown,profile.is_public,profile.bio_updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
status.blocked_at,status.banned_until,deleted.deleted_at
FROM users
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN profile_moderation moderation ON moderation.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 deleted_accounts deleted ON deleted.user_id=users.id
WHERE users.id=?`)).get(params.id);
const isSelf = viewer?.id === params.id;
if (
unavailable(agent)
|| (!Boolean(agent?.is_public) && !isSelf)
) error(404, 'Agent profile not found');
const [buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
await Promise.all([
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND (guide_body IS NULL OR length(trim(guide_body))=0)
ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
(await database.prepare(`SELECT comments.id,comments.body_markdown,comments.created_at,
thread.target_type,thread.target_id,builds.title AS build_title,builds.slug,
COALESCE(target_profile.display_name,target_user.username) AS target_display_name
FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
LEFT JOIN users target_user ON target_user.id=thread.target_id AND thread.target_type='profile'
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
LEFT JOIN deleted_accounts target_deleted ON target_deleted.user_id=target_user.id
LEFT JOIN user_status target_status ON target_status.user_id=target_user.id
WHERE comments.author_id=? AND comments.status='visible' AND (
(thread.target_type IN ('build','guide') AND builds.visibility='public')
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)
ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id, new Date().toISOString()),
(await database.prepare(`SELECT comments.id,comments.author_id,comments.body_markdown,
comments.created_at,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
FROM comment_threads thread
JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
LEFT JOIN users ON users.id=comments.author_id
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
WHERE thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 100`)).all(params.id),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
LEFT JOIN user_profiles target_profile ON target_profile.user_id=thread.target_id
AND thread.target_type='profile'
LEFT JOIN deleted_accounts target_deleted ON target_deleted.user_id=thread.target_id
LEFT JOIN user_status target_status ON target_status.user_id=thread.target_id
WHERE comments.author_id=? AND comments.status='visible' AND (
(thread.target_type IN ('build','guide') AND builds.visibility='public')
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)`)).get(params.id, new Date().toISOString()),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
.get(params.id),
(await database.prepare(`SELECT
SUM(CASE WHEN guide_body IS NULL OR length(trim(guide_body))=0 THEN 1 ELSE 0 END) AS builds,
SUM(CASE WHEN guide_body IS NOT NULL AND length(trim(guide_body))>0 THEN 1 ELSE 0 END) AS guides
FROM builds WHERE owner_id=? AND visibility='public'`)).get(params.id)
]);
return {
agent: {
id: String(agent.id),
displayName: String(agent.display_name),
avatarUrl: avatarUrl(
agent.avatar_source,
agent.avatar_value,
String(agent.id),
agent.avatar_updated_at
),
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown)
},
counts: {
builds: Number(contentCounts?.builds ?? 0),
guides: Number(contentCounts?.guides ?? 0),
authoredComments: Number(authoredCount?.count ?? 0),
profileComments: Number(profileCommentCount?.count ?? 0)
},
builds: buildRows.map((build) => ({
id: String(build.id),
slug: String(build.slug),
title: String(build.title),
summary: String(build.summary ?? ''),
updatedAt: String(build.updated_at)
})),
guides: guideRows.map((guide) => ({
id: String(guide.id),
slug: String(guide.slug),
title: String(guide.title),
summary: String(guide.summary ?? ''),
updatedAt: String(guide.updated_at)
})),
authoredComments: authoredCommentRows.map((comment) => {
const targetType = String(comment.target_type);
const targetId = String(comment.target_id);
return {
id: String(comment.id),
bodyHtml: renderUserMarkdown(comment.body_markdown),
createdAt: String(comment.created_at),
targetType,
targetTitle: targetType === 'profile'
? `${String(comment.target_display_name ?? 'Agent')} profile`
: String(comment.build_title ?? 'Community content'),
targetHref: commentHref(targetType, comment.slug, targetId)
};
}),
profileComments: profileCommentRows.map((comment) => ({
id: String(comment.id),
bodyHtml: renderUserMarkdown(comment.body_markdown),
createdAt: String(comment.created_at),
author: {
id: String(comment.author_id),
displayName: String(comment.display_name),
avatarUrl: avatarUrl(
comment.avatar_source,
comment.avatar_value,
String(comment.author_id),
comment.avatar_updated_at
)
},
canDelete: viewer?.id === String(comment.author_id)
})),
viewer: {
id: viewer?.id ?? null,
canComment: Boolean(viewer) && Boolean(agent.is_public),
isSelf
}
};
};
export const actions: Actions = {
commentProfile: async ({ params, cookies, request }) => {
const commenter = await getSessionUser(cookies);
if (!commenter) return fail(401, { profileCommentError: 'Sign in to post a profile comment.' });
const body = normalizeUserMarkdown(
(await request.formData()).get('body'),
COMMENT_MAX_CHARACTERS
);
if (body === null || Array.from(body).length < 2) {
return fail(400, {
profileCommentError: `Comments must contain 2–${COMMENT_MAX_CHARACTERS.toLocaleString()} visible characters.`
});
}
const database = await getDatabase();
const now = new Date();
const rows = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,profile.is_public,
status.blocked_at,status.banned_until,deleted.deleted_at
FROM users
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
WHERE users.id IN (?,?)`)).all(commenter.id, params.id);
const commenterState = rows.find((row) => row.id === commenter.id);
const ownerState = rows.find((row) => row.id === params.id);
if (unavailable(commenterState)) {
return fail(403, { profileCommentError: 'This account cannot post comments.' });
}
if (unavailable(ownerState) || !Boolean(ownerState?.is_public)) {
return fail(404, { profileCommentError: 'Agent profile not found.' });
}
const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
if (typeof recent?.created_at === 'string') {
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
if (elapsed < RAPID_COMMENT_SECONDS) {
return fail(429, { profileCommentError: 'Wait a few seconds before posting again.' });
}
if (elapsed < DUPLICATE_COMMENT_SECONDS && recent.body_markdown === body) {
return fail(409, { profileCommentError: 'That comment was already posted.' });
}
}
const createdAt = now.toISOString();
const proposedThreadId = randomUUID();
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
.run(proposedThreadId, params.id, createdAt);
const thread = await (await database.prepare(
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
)).get(params.id);
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
VALUES(?,?,?,NULL,?,'visible',?,?)`))
.run(commentId, thread.id, commenter.id, body, createdAt, createdAt);
if (commenter.id !== params.id) {
await createNotification({
userId: params.id,
category: 'comments',
source: 'agent',
title: 'New profile comment',
body: `${commenter.displayName} posted a comment on your Agent profile.`,
link: `/agents/${encodeURIComponent(params.id)}#profile-comments`,
actions: [{
label: 'VIEW COMMENT',
href: `/agents/${encodeURIComponent(params.id)}#profile-comments`
}]
});
}
return {
profileCommentSaved: true,
profileCommentId: commentId,
profileCommentResultToken: randomUUID()
};
},
deleteProfileComment: async ({ params, cookies, request }) => {
const viewer = await getSessionUser(cookies);
if (!viewer) return fail(401, { profileCommentError: 'Sign in to remove a comment.' });
const commentId = String((await request.formData()).get('commentId') ?? '').trim();
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
const database = await getDatabase();
const comment = await (await database.prepare(`SELECT comments.id,comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, params.id);
if (!comment) return fail(404, { profileCommentError: 'That comment is no longer available.' });
if (comment.author_id !== viewer.id) {
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
}
const removedAt = new Date().toISOString();
await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,removed_by=?,
removal_reason='Removed by author',updated_at=? WHERE id=? AND status='visible'`))
.run(removedAt, viewer.id, removedAt, commentId);
return {
profileCommentDeleted: true,
profileCommentId: commentId,
profileCommentResultToken: randomUUID()
};
}
};
+229
View File
@@ -0,0 +1,229 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
type PublicAgent = {
id: string;
displayName: string;
avatarUrl: string;
bioHtml: string;
};
type PublicContent = {
id: string;
slug: string;
title: string;
summary: string | null;
updatedAt: string;
};
type AuthoredComment = {
id: string;
bodyHtml: string;
createdAt: string;
targetType: string;
targetTitle: string;
targetHref: string;
};
type ProfileComment = {
id: string;
bodyHtml: string;
createdAt: string;
author: { id: string; displayName: string; avatarUrl: string };
canDelete: boolean;
};
type PageData = {
agent: PublicAgent;
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
builds: PublicContent[];
guides: PublicContent[];
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; isSelf: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let profileCommentDraft = $state('');
let deletingCommentId = $state('');
let handledCommentToken = $state('');
const dateLabel = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.valueOf())
? 'DATE UNKNOWN'
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
};
$effect(() => {
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
if (!token || token === handledCommentToken) return;
handledCommentToken = token;
if (form?.profileCommentSaved || form?.commentSaved) profileCommentDraft = '';
if (form?.profileCommentDeleted || form?.commentDeleted) deletingCommentId = '';
});
</script>
<svelte:head>
<title>{data.agent.displayName} — Agent Profile — SHD.build</title>
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, guides, and community activity on SHD.build.`}/>
</svelte:head>
<main class="page public-agent-page">
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
<header class="agent-command">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<div class="agent-command-actions">
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
</div>
</header>
<section class="agent-telemetry" aria-label="Agent content totals">
<div><span>PUBLIC BUILDS</span><strong>{data.counts.builds}</strong></div>
<div><span>PUBLISHED GUIDES</span><strong>{data.counts.guides}</strong></div>
<div><span>CONTENT COMMENTS</span><strong>{data.counts.authoredComments}</strong></div>
<div><span>PROFILE MESSAGES</span><strong>{data.counts.profileComments}</strong></div>
</section>
<div class="agent-profile-grid">
<div class="agent-primary">
<section class="profile-section agent-bio">
<header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><span>PUBLIC BIO</span></header>
<div class="bio-content">
<MarkdownContent html={data.agent.bioHtml} emptyMessage="This Agent has not published a field bio yet." />
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
<div class="public-content-grid">
{#each data.builds as build}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}>
<span>LOADOUT</span>
<h3>{build.title}</h3>
<p>{build.summary || 'Open this loadout to inspect its equipment, Attributes, and calculated performance.'}</p>
<footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC BUILDS</strong>
<p>This Agent has not shared a public loadout.</p>
</div>
{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">FIELD GUIDANCE</p><h2>PUBLISHED GUIDES</h2></div><span>{data.counts.guides} TOTAL</span></header>
<div class="public-content-grid">
{#each data.guides as guide}
<a class="public-content-card guide-card" href={`/builds/${encodeURIComponent(guide.slug)}`}>
<span>GUIDE</span>
<h3>{guide.title}</h3>
<p>{guide.summary || 'Open this guide for the complete strategy and field notes.'}</p>
<footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLISHED GUIDES</strong>
<p>This Agent has not published a guide.</p>
</div>
{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">COMMUNITY RECORD</p><h2>RECENT DISCUSSION</h2></div><span>{data.counts.authoredComments} COMMENTS</span></header>
<div class="authored-comment-list">
{#each data.authoredComments as comment}
<article>
<header><div><span>{comment.targetType.toUpperCase()}</span><strong>{comment.targetTitle}</strong></div><small>{dateLabel(comment.createdAt)}</small></header>
<MarkdownContent html={comment.bodyHtml} compact />
<a href={comment.targetHref}>OPEN DISCUSSION →</a>
</article>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC DISCUSSION</strong>
<p>Comments this Agent leaves on builds and guides will appear here.</p>
</div>
{/each}
</div>
</section>
</div>
<aside class="profile-section profile-comms">
<header><div><p class="eyebrow">AGENT COMMS</p><h2>PROFILE MESSAGES</h2></div><span>{data.counts.profileComments}</span></header>
{#if data.viewer.canComment}
<PendingForm action="?/commentProfile" class="profile-comment-form">
<input type="hidden" name="agentId" value={data.agent.id}/>
<MarkdownEditor
name="body"
label="LEAVE A MESSAGE"
help="Keep profile messages relevant, respectful, and useful to the Agent."
maxLength={2000}
rows={6}
required
bind:value={profileCommentDraft}
/>
{#if form?.profileCommentError || form?.commentError}<p class="error">{form?.profileCommentError ?? form?.commentError}</p>{/if}
{#if form?.profileCommentSaved || form?.commentSaved}<p class="success">Profile message transmitted.</p>{/if}
<button class="primary" disabled={!profileCommentDraft.trim()}>POST MESSAGE →</button>
</PendingForm>
{:else if !data.viewer.id}
<div class="comment-access-note"><strong>SIGN-IN REQUIRED</strong><p>Sign in to leave a message on this Agent profile.</p><a class="secondary" href="/sign-in">SIGN IN →</a></div>
{:else}
<div class="comment-access-note"><strong>MESSAGING UNAVAILABLE</strong><p>Your account cannot post profile messages at this time.</p></div>
{/if}
<div class="profile-comment-list">
{#each data.profileComments as comment}
<article>
<header>
<AgentIdentityLink agent={comment.author} size="compact" meta={dateLabel(comment.createdAt)} />
{#if comment.canDelete}
<button type="button" class="comment-remove" aria-expanded={deletingCommentId === comment.id} onclick={() => deletingCommentId = comment.id}>REMOVE</button>
{/if}
</header>
<MarkdownContent html={comment.bodyHtml} compact />
{#if deletingCommentId === comment.id}
<div class="comment-delete-confirm" role="alert">
<p>Remove this profile message?</p>
<div>
<button type="button" class="secondary" onclick={() => deletingCommentId = ''}>CANCEL</button>
<PendingForm action="?/deleteProfileComment">
<input type="hidden" name="commentId" value={comment.id}/>
<button class="primary">CONFIRM REMOVE</button>
</PendingForm>
</div>
</div>
{/if}
</article>
{:else}
<div class="public-empty compact">
<span>◇</span><strong>NO PROFILE MESSAGES</strong>
<p>Be the first Agent to leave a useful field message.</p>
</div>
{/each}
</div>
</aside>
</div>
</main>
<style>
.public-agent-page{padding-top:28px}.agent-command{min-height:116px;display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-lg);margin-top:12px;padding:20px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.agent-command :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-telemetry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:8px;border:1px solid var(--shd-border);background:var(--shd-surface)}
.agent-telemetry>div{display:grid;gap:4px;padding:10px 12px;border-left:1px solid var(--shd-border)}.agent-telemetry>div:first-child{border-left:0}.agent-telemetry span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.agent-telemetry strong{color:var(--shd-orange);font-size:24px}
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(330px,.38fr);gap:10px;margin-top:10px}.agent-primary{display:grid;gap:10px;min-width:0}
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.bio-content{min-height:110px;padding:18px}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card.guide-card{border-left-color:var(--shd-info)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.guide-card>span{color:var(--shd-info)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{display:flex;justify-content:space-between;gap:10px}.authored-comment-list article>header>div{display:grid;gap:3px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{color:var(--shd-text-muted)}.authored-comment-list article>a{justify-self:end}
.profile-comms{align-self:start;position:sticky;top:88px}:global(form.profile-comment-form){display:grid;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border)}:global(form.profile-comment-form > button){min-height:40px}.profile-comment-list{display:grid}.profile-comment-list>article{display:grid;gap:9px;padding:10px;border-bottom:1px solid var(--shd-border)}.profile-comment-list>article:last-child{border-bottom:0}.profile-comment-list article>header{display:flex;align-items:center;justify-content:space-between;gap:8px}.profile-comment-list article>header :global(.agent-identity){flex:1}.comment-remove{min-height:32px;padding:0 7px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.comment-remove:hover,.comment-remove:focus-visible{background:var(--shd-danger);color:var(--shd-bg)}.comment-delete-confirm{display:grid;gap:8px;padding:8px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{display:grid;gap:7px;padding:12px;border-bottom:1px solid var(--shd-border)}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.profile-comms{position:static}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{align-items:stretch;flex-direction:column}.agent-command-actions a{width:100%;min-height:44px;justify-content:center}.agent-telemetry{grid-template-columns:1fr}.agent-telemetry>div{border-top:1px solid var(--shd-border);border-left:0}.agent-telemetry>div:first-child{border-top:0}.public-content-grid{grid-template-columns:1fr}.profile-section>header{align-items:flex-start;flex-direction:column}:global(form.profile-comment-form > button),.comment-delete-confirm button{min-height:44px}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.comment-remove{transition:none}}
</style>
+20 -2
View File
@@ -1,20 +1,38 @@
import { getDatabase } from '$lib/server/database';
import type { PageServerLoad } from './$types';
import { avatarUrl } from '$lib/avatar';
export const load: PageServerLoad = async () => {
const database = await getDatabase();
const builds = await (await database.prepare(`
SELECT builds.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.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at,
COALESCE(AVG(ratings.value),0) AS rating,COUNT(ratings.value) AS votes
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
LEFT JOIN ratings ON ratings.build_id=builds.id
WHERE builds.visibility='public' AND builds.loadout_json IS NOT NULL
GROUP BY builds.id
ORDER BY builds.updated_at DESC
LIMIT 60
`)).all();
return { builds };
return {
builds: builds.map((build) => ({
...build,
ownerId: String(build.owner_id),
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at
)
}))
};
};
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
let { data } = $props();
</script>
@@ -13,7 +14,7 @@
<article style="--build-color:#f6a821">
<div class="card-top"><span>PUBLIC</span><span>{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}</span></div>
<div class="weapon-art"><div></div><span>SHD LOADOUT</span></div>
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><span class="avatar">{String(build.author).slice(0,1)}</span><span>BY <strong>{build.author}</strong></span><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), avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
</article>
{:else}
<div class="catalogue-empty"><span>◇</span><strong>NO PUBLIC BUILDS YET</strong><p>Be the first agent to publish a loadout to the archive.</p><a class="primary" href="/builder">CREATE BUILD →</a></div>
+15 -1
View File
@@ -4,16 +4,22 @@ import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import { avatarUrl } from '$lib/avatar';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
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,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=?
`)).get(params.slug);
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
@@ -38,6 +44,14 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
build: {
id: String(build.id), title: String(build.title), slug: String(build.slug),
visibility: String(build.visibility), author: String(build.author),
ownerId: String(build.owner_id),
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at
),
owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at)
},
loadout, gear, weapons, chips, protocols, brands, combatResults
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id);
@@ -12,7 +13,7 @@
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
<main class="app-page shared-build-page">
<header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><p>Filed by <strong>{data.build.author}</strong> · Updated {new Date(data.build.updatedAt).toLocaleDateString()}</p></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, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
+5 -2
View File
@@ -33,12 +33,15 @@
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections: Array<{ href: string; label: string; permission: string; reviewKey?: 'data' | 'avatars' }> = [
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' }> = [
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'] },
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const visibleOversightSections = $derived(oversightSections.filter((section) =>
section.permissions?.some(can) || (section.permission ? can(section.permission) : false)
));
const systemSections = [
{ href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' },
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
+1
View File
@@ -87,6 +87,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation,
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
+1
View File
@@ -36,6 +36,7 @@
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
{value:'ai_assistance',label:'AI ASSISTANCE',count:data.summary.ai_assistance,description:'Permission-gated mechanic drafting'},
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'community_moderation',label:'COMMUNITY',count:data.summary.community_moderation,description:'Profile and comment moderation'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
,{value:'system_configuration',label:'SYSTEM CONFIG',count:data.summary.system_configuration,description:'Automated messages and options'}
+473
View File
@@ -0,0 +1,473 @@
import { avatarUrl } from '$lib/avatar';
import { recordIsacAudit } from '$lib/server/audit';
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { renderUserMarkdown } from '$lib/markdown';
import { createNotification } from '$lib/server/notifications';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
const REMOVED_RETENTION_DAYS = 30;
const has = (permissions: string[], permission: string) =>
permissions.includes('*') || permissions.includes(permission);
const actionFailure = (status: number, message: string) => fail(status, {
communityError: message,
communityResultToken: randomUUID()
});
const requiredReason = (value: FormDataEntryValue | null) => {
const reason = String(value ?? '').normalize('NFKC').trim().slice(0, 500);
return reason.length >= 3 ? reason : null;
};
const targetHref = (targetType: string, targetId: unknown, slug: unknown) =>
targetType === 'profile'
? `/agents/${encodeURIComponent(String(targetId))}#profile-comments`
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
const parseAuditSnapshot = (value: unknown) => {
if (typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await getSessionUser(cookies);
if (!operator) error(401, 'Sign in required');
const granted = await getPermissions(operator);
const canModerateComments = has(granted, COMMENT_PERMISSION);
const canModerateProfiles = has(granted, PROFILE_PERMISSION);
if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required');
const requestedTab = url.searchParams.get('tab');
const tab = requestedTab === 'bios'
? 'bios'
: requestedTab === 'comments'
? 'comments'
: canModerateComments ? 'comments' : 'bios';
if (tab === 'comments' && !canModerateComments) error(403, 'Comment moderation permission required');
if (tab === 'bios' && !canModerateProfiles) error(403, 'Profile moderation permission required');
const allowedStatuses = tab === 'comments' ? ['all', 'visible', 'removed'] : ['all', 'visible', 'hidden'];
const status = allowedStatuses.includes(String(url.searchParams.get('status')))
? String(url.searchParams.get('status'))
: 'visible';
const { search, pattern } = getRegistrySearch(url);
const database = await getDatabase();
const removedCutoff = new Date(Date.now() - REMOVED_RETENTION_DAYS * 86_400_000).toISOString();
const commentSummaryRows = canModerateComments
? await (await database.prepare(`SELECT status,COUNT(*) AS count FROM comments
WHERE status='visible' OR (status='removed' AND removed_at>=?)
GROUP BY status`)).all(removedCutoff)
: [];
const visibleBios = canModerateProfiles
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0
AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
: 0;
const hiddenBios = canModerateProfiles
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE moderation.bio_hidden=1 AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
: 0;
const commentSummary = Object.fromEntries(
commentSummaryRows.map((row) => [String(row.status), Number(row.count)])
);
if (tab === 'comments') {
const sort = getRegistrySort(url, {
created: {
asc: 'comments.created_at ASC',
desc: 'comments.created_at DESC',
defaultDirection: 'desc'
},
updated: {
asc: 'comments.updated_at ASC',
desc: 'comments.updated_at DESC',
defaultDirection: 'desc'
},
author: {
asc: 'display_name COLLATE NOCASE ASC',
desc: 'display_name COLLATE NOCASE DESC'
}
}, 'created', 'desc');
const conditions: string[] = [];
const args: unknown[] = [];
if (status === 'all') {
conditions.push("(comments.status='visible' OR (comments.status='removed' AND comments.removed_at>=?))");
args.push(removedCutoff);
} else {
conditions.push('comments.status=?');
args.push(status);
}
if (status === 'removed') {
conditions.push('comments.removed_at>=?');
args.push(removedCutoff);
}
if (search) {
conditions.push(`(comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(profile.display_name,users.username,'Deleted Agent') LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(builds.title,target_profile.display_name,target_user.username,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern);
}
const joins = `FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN users ON users.id=comments.author_id
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
LEFT JOIN users target_user ON target_user.id=thread.target_id AND thread.target_type='profile'
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id`;
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(
`SELECT COUNT(*) AS count ${joins} ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
comments.created_at,comments.updated_at,comments.removal_reason,
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
thread.target_type,thread.target_id,builds.slug,
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
${joins} ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({
id: String(row.id),
bodyHtml: renderUserMarkdown(row.body_markdown),
status: String(row.status),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
targetType: String(row.target_type),
targetTitle: String(row.target_title),
targetHref: targetHref(String(row.target_type), row.target_id, row.slug),
author: {
id: String(row.author_id),
displayName: String(row.display_name),
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.author_id), row.avatar_updated_at)
}
})),
pagination,
sort: { key: sort.key, direction: sort.direction },
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
}
const sort = getRegistrySort(url, {
updated: {
asc: 'profile.updated_at ASC',
desc: 'profile.updated_at DESC',
defaultDirection: 'desc'
},
agent: {
asc: 'display_name COLLATE NOCASE ASC',
desc: 'display_name COLLATE NOCASE DESC'
}
}, 'updated', 'desc');
const conditions = [
status === 'all'
? "((profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0) OR moderation.bio_hidden=1)"
: status === 'visible'
? 'profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0'
: 'moderation.bio_hidden=1',
'length(trim(profile.bio_markdown))>0'
];
const args: unknown[] = [];
if (search) {
conditions.push(`(profile.bio_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(profile.display_name,users.username) LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count
FROM user_profiles profile JOIN users ON users.id=profile.user_id
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
${where}`)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,profile.is_public,
profile.updated_at,COALESCE(profile.display_name,users.username) AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
moderation.reason AS moderation_reason
FROM user_profiles profile
JOIN users ON users.id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN profile_moderation moderation ON moderation.user_id=users.id
${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({
id: String(row.user_id),
bodyHtml: renderUserMarkdown(row.bio_markdown),
status,
updatedAt: String(row.updated_at),
moderationReason: row.moderation_reason ? String(row.moderation_reason) : null,
agent: {
id: String(row.user_id),
displayName: String(row.display_name),
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.user_id), row.avatar_updated_at)
}
})),
pagination,
sort: { key: sort.key, direction: sort.direction },
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
};
export const actions: Actions = {
removeComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief removal reason.');
const database = await getDatabase();
const comment = await (await database.prepare(`SELECT comments.*,thread.target_type,thread.target_id,builds.slug
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
WHERE comments.id=? AND comments.status='visible'`)).get(id);
if (!comment) return actionFailure(404, 'That visible comment no longer exists.');
const before = {
status: 'visible',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
targetType: String(comment.target_type),
targetId: String(comment.target_id),
updatedAt: String(comment.updated_at)
};
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,
removed_by=?,removal_reason=?,updated_at=? WHERE id=? AND status='visible'`))
.run(now, operator.id, reason, now, id);
if (!result.changes) return actionFailure(409, 'That comment changed before it could be removed.');
const after = { ...before, status: 'removed', removedAt: now, removedBy: operator.id, reason, updatedAt: now };
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'comment.removed',
entityId: id,
before,
after
});
await createNotification({
userId: String(comment.author_id),
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: 'Comment removed',
body: `Community Oversight removed one of your comments. Reason: ${reason}`,
link: targetHref(String(comment.target_type), comment.target_id, comment.slug),
actions: [{
label: 'VIEW DISCUSSION',
href: targetHref(String(comment.target_type), comment.target_id, comment.slug)
}],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
restoreComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief restoration reason.');
const database = await getDatabase();
const comment = await (await database.prepare(`SELECT * FROM comments WHERE id=? AND status='removed'`)).get(id);
if (!comment) return actionFailure(404, 'That removed comment no longer exists.');
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
WHERE entity_type='community_moderation' AND entity_id=? AND action='comment.removed'
ORDER BY created_at DESC LIMIT 1`)).get(id);
const expected = parseAuditSnapshot(audit?.after_json);
if (
!expected
|| expected.status !== 'removed'
|| String(expected.removedAt ?? '') !== String(comment.removed_at ?? '')
|| String(expected.updatedAt ?? '') !== String(comment.updated_at ?? '')
) return actionFailure(409, 'This comment was not removed by Community Oversight or changed after removal.');
const before = {
status: 'removed',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
removedAt: String(comment.removed_at),
removedBy: String(comment.removed_by),
removalReason: String(comment.removal_reason),
updatedAt: String(comment.updated_at)
};
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE comments SET status='visible',removed_at=NULL,
removed_by=NULL,removal_reason=NULL,updated_at=? WHERE id=? AND status='removed' AND updated_at=?`))
.run(now, id, comment.updated_at);
if (!result.changes) return actionFailure(409, 'That comment changed before it could be restored.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'comment.restored',
entityId: id,
before,
after: {
status: 'visible',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
restorationReason: reason,
updatedAt: now
}
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
hideBio: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief reason for hiding it.');
const database = await getDatabase();
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
profile.is_public,profile.bio_updated_at,profile.updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason,
moderation.moderated_at,moderation.moderated_by
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=? AND profile.is_public=1
AND COALESCE(moderation.bio_hidden,0)=0 AND length(trim(profile.bio_markdown))>0`)).get(id);
if (!profile) return actionFailure(404, 'That visible profile biography no longer exists.');
const before = {
bioMarkdown: String(profile.bio_markdown),
isPublic: true,
bioHidden: false,
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
profileUpdatedAt: String(profile.updated_at),
moderation: profile.moderated_at ? {
reason: profile.reason ? String(profile.reason) : null,
moderatedAt: String(profile.moderated_at),
moderatedBy: profile.moderated_by ? String(profile.moderated_by) : null
} : null
};
const now = new Date().toISOString();
const result = await (await database.prepare(`INSERT INTO profile_moderation
(user_id,bio_hidden,reason,moderated_at,moderated_by) VALUES(?,1,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET bio_hidden=1,reason=excluded.reason,
moderated_at=excluded.moderated_at,moderated_by=excluded.moderated_by
WHERE profile_moderation.bio_hidden=0`)).run(id, reason, now, operator.id);
if (!result.changes) return actionFailure(409, 'That profile changed before it could be hidden.');
const after = {
...before,
bioHidden: true,
reason,
moderatedAt: now,
moderatedBy: operator.id
};
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'profile.hidden',
entityId: id,
before,
after
});
await createNotification({
userId: id,
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: 'Agent profile hidden',
body: `Community Oversight hid your public Agent profile. Your biography is preserved but is not publicly visible. Reason: ${reason}`,
link: '/account',
actions: [{ label: 'REVIEW PROFILE', href: '/account' }],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
restoreBio: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief restoration reason.');
const database = await getDatabase();
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
profile.is_public,profile.bio_updated_at,profile.updated_at,
moderation.bio_hidden,moderation.reason,moderation.moderated_at,moderation.moderated_by
FROM user_profiles profile
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=? AND moderation.bio_hidden=1
AND length(trim(profile.bio_markdown))>0`)).get(id);
if (!profile) return actionFailure(404, 'That hidden profile biography no longer exists.');
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
WHERE entity_type='community_moderation' AND entity_id=? AND action='profile.hidden'
ORDER BY created_at DESC LIMIT 1`)).get(id);
const expected = parseAuditSnapshot(audit?.after_json);
if (
!expected
|| expected.bioHidden !== true
|| String(expected.bioMarkdown ?? '') !== String(profile.bio_markdown)
|| String(expected.bioUpdatedAt ?? '') !== String(profile.bio_updated_at ?? '')
|| String(expected.profileUpdatedAt ?? '') !== String(profile.updated_at)
|| String(expected.moderatedAt ?? '') !== String(profile.moderated_at)
|| String(expected.moderatedBy ?? '') !== String(profile.moderated_by)
) return actionFailure(409, 'This profile was not hidden by Community Oversight or changed after it was hidden.');
const before = {
bioMarkdown: String(profile.bio_markdown),
isPublic: Boolean(profile.is_public),
bioHidden: true,
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
profileUpdatedAt: String(profile.updated_at),
reason: String(profile.reason),
moderatedAt: String(profile.moderated_at),
moderatedBy: String(profile.moderated_by)
};
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE profile_moderation
SET bio_hidden=0,reason=?,moderated_at=?,moderated_by=?
WHERE user_id=? AND bio_hidden=1 AND moderated_at=?`))
.run(reason, now, operator.id, id, profile.moderated_at);
if (!result.changes) return actionFailure(409, 'That profile changed before it could be restored.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'profile.restored',
entityId: id,
before,
after: {
...before,
bioHidden: false,
restorationReason: reason,
moderatedAt: now,
moderatedBy: operator.id
}
});
return { communitySaved: true, communityResultToken: randomUUID() };
}
};
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
type CommunityComment = {
id: string;
bodyHtml: string;
status: 'visible' | 'removed';
createdAt: string;
updatedAt: string;
moderationReason: string | null;
targetType: string;
targetTitle: string;
targetHref: string;
author: AgentSummary;
};
type CommunityBio = {
id: string;
bodyHtml: string;
status: 'visible' | 'hidden';
updatedAt: string;
moderationReason: string | null;
agent: AgentSummary;
};
type CommunityItem = CommunityComment | CommunityBio;
type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio';
type PageData = {
tab: 'comments' | 'bios';
status: string;
search: string;
summary: {
comments: { visible: number; removed: number };
bios: { visible: number; hidden: number };
};
items: CommunityItem[];
pagination: any;
permissions: { comments: boolean; profiles: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let reviewing = $state<CommunityItem | null>(null);
let moderationAction = $state<ModerationAction | null>(null);
let moderationReason = $state('');
let confirming = $state(false);
let handledResultToken = $state('');
const canModerateComments = $derived(data.permissions.comments);
const canModerateProfiles = $derived(data.permissions.profiles);
const isComment = (item: CommunityItem): item is CommunityComment => 'author' in item;
const itemAgent = (item: CommunityItem) => isComment(item) ? item.author : item.agent;
const itemTimestamp = (item: CommunityItem) => isComment(item) ? item.createdAt : item.updatedAt;
const date = (value: string) => {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf())
? 'DATE UNKNOWN'
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed);
};
const actionRoute = $derived(
moderationAction === 'removeComment' ? '?/removeComment'
: moderationAction === 'restoreComment' ? '?/restoreComment'
: moderationAction === 'hideBio' ? '?/hideBio'
: '?/restoreBio'
);
const actionLabel = $derived(
moderationAction === 'removeComment' ? 'REMOVE COMMENT'
: moderationAction === 'restoreComment' ? 'RESTORE COMMENT'
: moderationAction === 'hideBio' ? 'HIDE PROFILE BIO'
: 'RESTORE PROFILE BIO'
);
const actionConsequence = $derived(
moderationAction === 'removeComment'
? 'The comment will no longer appear on its public target or the author’s public activity.'
: moderationAction === 'restoreComment'
? 'The comment will return to its public target and the author’s public activity.'
: moderationAction === 'hideBio'
? 'The current bio will be hidden from the Agent’s public profile.'
: 'The bio will return to the Agent’s public profile.'
);
const statusItems = $derived(
data.tab === 'comments'
? [
{ value: 'all', label: 'ALL', count: data.summary.comments.visible + data.summary.comments.removed, description: 'All public and moderated comments' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.comments.visible, description: 'Comments currently visible to the community' },
{ value: 'removed', label: 'REMOVED', count: data.summary.comments.removed, description: 'Comments removed by Oversight' }
]
: [
{ value: 'all', label: 'ALL', count: data.summary.bios.visible + data.summary.bios.hidden, description: 'All public and moderated bios' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.bios.visible, description: 'Bios currently visible on Agent profiles' },
{ value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' }
]
);
function openModeration(item: CommunityItem) {
reviewing = item;
moderationAction = isComment(item)
? item.status === 'removed' ? 'restoreComment' : 'removeComment'
: item.status === 'hidden' ? 'restoreBio' : 'hideBio';
moderationReason = '';
confirming = false;
}
function closeModeration() {
reviewing = null;
moderationAction = null;
moderationReason = '';
confirming = false;
}
$effect(() => {
const token = String(form?.communityResultToken ?? '');
if (!token || token === handledResultToken) return;
handledResultToken = token;
if (form?.communitySaved) closeModeration();
});
</script>
<svelte:head><title>Community Oversight — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key === 'Escape' && reviewing) closeModeration();
}}/>
<div class="module-toolbar moderation-toolbar">
<div>
<p class="eyebrow">COMMUNITY OVERSIGHT / PUBLIC CONTENT CONTROL</p>
<h2>COMMUNITY OVERSIGHT</h2>
<p>Review public discussion and Agent bios. Moderation preserves evidence, requires a reason, and remains reversible.</p>
</div>
<div class="queue-readout">
<span>{data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span>
<strong>{data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong>
<small>PUBLIC RECORDS</small>
</div>
</div>
<nav class="community-tabs" aria-label="Community oversight channels">
{#if canModerateComments}
<a class:active={data.tab === 'comments'} aria-current={data.tab === 'comments' ? 'page' : undefined} href="/isac/community?tab=comments&status=all">
<span>COMMENTS</span><b>{data.summary.comments.visible + data.summary.comments.removed}</b>
</a>
{/if}
{#if canModerateProfiles}
<a class:active={data.tab === 'bios'} aria-current={data.tab === 'bios' ? 'page' : undefined} href="/isac/community?tab=bios&status=all">
<span>PROFILE BIOS</span><b>{data.summary.bios.visible + data.summary.bios.hidden}</b>
</a>
{/if}
</nav>
<KpiFilters parameter="status" active={data.status} items={statusItems}/>
<form class="audit-filter community-filter">
<input type="hidden" name="tab" value={data.tab}/>
<input type="hidden" name="status" value={data.status}/>
<input name="search" type="search" value={data.search} placeholder={data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/>
<button class="secondary">SEARCH COMMUNITY →</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="community-registry">
{#each data.items as item}
<article class:moderated={item.status === 'removed' || item.status === 'hidden'}>
<header>
<AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<div class="community-state">
<span>{date(itemTimestamp(item))}</span>
<b class:visible={item.status === 'visible'}>{item.status.toUpperCase()}</b>
</div>
</header>
{#if isComment(item)}
<div class="community-target">
<span>{item.targetType.toUpperCase()}</span>
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
{:else}
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
{/if}
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
</div>
{#if item.moderationReason}
<div class="moderation-evidence"><span>LAST OVERSIGHT REASON</span><p>{item.moderationReason}</p></div>
{/if}
<footer>
<span>{isComment(item) ? `COMMENT ${item.id.slice(0,8).toUpperCase()}` : `AGENT ${item.id.slice(0,8).toUpperCase()}`}</span>
{#if (isComment(item) && canModerateComments) || (!isComment(item) && canModerateProfiles)}
<button class={item.status === 'visible' ? 'danger' : 'secondary'} onclick={() => openModeration(item)}>
{item.status === 'visible' ? isComment(item) ? 'REMOVE COMMENT →' : 'HIDE BIO →' : isComment(item) ? 'RESTORE COMMENT →' : 'RESTORE BIO →'}
</button>
{/if}
</footer>
</article>
{:else}
<div class="empty-dashboard">
<strong>NO MATCHING {data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong>
<span>Adjust the status or search filters to inspect another part of the community record.</span>
</div>
{/each}
</div>
{#if reviewing && moderationAction}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="community-review-title">
<div class="editor-title">
<div><p class="eyebrow">COMMUNITY CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="community-review-title">{actionLabel}</h3></div>
<button aria-label="Close community moderation review" onclick={closeModeration}>×</button>
</div>
<div class="community-review-identity">
<AgentIdentityLink agent={itemAgent(reviewing)} meta={isComment(reviewing) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<span>{date(itemTimestamp(reviewing))}</span>
</div>
{#if isComment(reviewing)}
<div class="community-target review-target"><span>{reviewing.targetType.toUpperCase()}</span><a href={reviewing.targetHref}>{reviewing.targetTitle} →</a></div>
{/if}
<div class="community-review-content"><MarkdownContent html={reviewing.bodyHtml} emptyMessage="No content is available for review."/></div>
{#if reviewing.moderationReason}<div class="moderation-evidence"><span>PREVIOUS OVERSIGHT REASON</span><p>{reviewing.moderationReason}</p></div>{/if}
{#if !confirming}
<label class="field moderation-reason">
OVERSIGHT REASON — REQUIRED
<textarea bind:value={moderationReason} maxlength="500" required placeholder="Record a specific policy, safety, or restoration reason…"></textarea>
</label>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="moderation-warning" class:rejecting={moderationAction === 'removeComment' || moderationAction === 'hideBio'}>
<b>!</b><span>{actionConsequence} The original content and this reason remain available to authorized Oversight.</span>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick={closeModeration}>CANCEL</button>
<button type="button" class="primary" disabled={!moderationReason.trim()} onclick={() => confirming = true}>REVIEW ACTION →</button>
</div>
{:else}
{@const reviewingId = reviewing.id}
<div class="community-final-confirm" role="alert">
<div><span>FINAL CONFIRMATION</span><strong>{actionLabel}?</strong><p>{actionConsequence}</p></div>
<dl><dt>OVERSIGHT REASON</dt><dd>{moderationReason}</dd></dl>
</div>
<PendingForm action={actionRoute}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewingId}/>
<input type="hidden" name="reason" value={moderationReason}/>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => confirming = false}>BACK</button>
<button class={moderationAction === 'removeComment' || moderationAction === 'hideBio' ? 'danger' : 'primary'} disabled={pending} aria-busy={pending}>
{pending ? 'PROCESSING…' : `CONFIRM ${actionLabel} →`}
</button>
</div>
{/snippet}
</PendingForm>
{/if}
</div>
</div>
{/if}
<style>
.community-tabs{height:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011}
.community-tabs a{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 11px;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;color:var(--shd-text-muted)}.community-tabs a:first-child{border-left:0}.community-tabs a span,.community-tabs a b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-tabs a b{color:var(--shd-text-dim)}.community-tabs a:hover,.community-tabs a:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)}.community-tabs a.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.community-tabs a.active b{color:var(--shd-orange)}
.community-filter{grid-template-columns:minmax(0,1fr) auto}.community-registry{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:7px}.community-registry>article{min-width:0;display:grid;grid-template-rows:auto auto minmax(100px,1fr) auto;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}.community-registry>article.moderated{border-left-color:var(--shd-danger)}
.community-registry article>header{min-height:55px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>header :global(.agent-identity){flex:1}.community-state{display:grid;justify-items:end;gap:4px}.community-state span,.community-state b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-state span{color:var(--shd-text-muted)}.community-state b{padding:3px 5px;border:1px solid var(--shd-danger);color:var(--shd-danger)}.community-state b.visible{border-color:var(--shd-success);color:var(--shd-success)}
.community-target{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-bottom:1px solid var(--shd-border-soft);background:#0d1011}.community-target span,.community-target a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-target span{color:var(--shd-text-muted)}.community-target a{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.community-markdown-preview{max-height:150px;overflow:hidden;padding:11px;position:relative}.community-markdown-preview:after{position:absolute;right:0;bottom:0;left:0;height:25px;background:linear-gradient(transparent,var(--shd-surface));content:"";pointer-events:none}
.moderation-evidence{display:grid;gap:4px;margin:0 10px 9px;padding:8px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-danger);background:#1b1111}.moderation-evidence span{color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.moderation-evidence p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.community-registry article>footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>footer>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-registry article>footer button{min-height:36px;padding:0 10px}
.community-review-modal{width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.community-review-identity{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border-bottom:1px solid var(--shd-border)}.community-review-identity :global(.agent-identity){flex:1}.community-review-identity>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.review-target{border-bottom:1px solid var(--shd-border)}.community-review-content{max-height:330px;overflow:auto;padding:14px;border-bottom:1px solid var(--shd-border);background:#0d1011}.community-review-content :global(.markdown-content){max-width:none}.moderation-reason{margin:10px}.moderation-reason textarea{min-height:90px}.community-final-confirm{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.65fr);gap:9px;margin:10px;padding:10px;border:1px solid var(--shd-orange);background:#1b160d}.community-final-confirm>div{display:grid;gap:4px}.community-final-confirm span,.community-final-confirm dt{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-final-confirm p,.community-final-confirm dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.community-final-confirm dl{display:grid;align-content:start;gap:5px;margin:0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.community-review-modal>.moderation-warning,.community-review-modal>.form-actions,.community-review-modal :global(form > .form-actions),.community-review-modal :global(form > .error){margin-right:10px;margin-left:10px}
@media(max-width:900px){.community-registry{grid-template-columns:1fr}}
@media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}}
@media(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}}
</style>
+2 -2
View File
@@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
deleted_accounts.deleted_at,
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
(SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
@@ -76,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
(SELECT provider FROM login_events WHERE user_id=users.id AND succeeded=1 ORDER BY created_at DESC LIMIT 1) AS last_login_provider,
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
(SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
+14 -4
View File
@@ -30,12 +30,17 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
const commentWhere = `comments.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
const [loginCount, buildCount, commentCount] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere}`)).get(...commentArgs)
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere}`)).get(...commentArgs)
]);
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
@@ -67,8 +72,13 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
(await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug
FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
(await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
,getAuthenticationMethods(database, params.id)
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id)
,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id)
+28 -6
View File
@@ -70,6 +70,12 @@
under a “Deleted Agent” attribution to preserve community references. You may not
upload unlawful, infringing, malicious, or privacy-invasive content.
</p>
<p>
Community Oversight may hide or remove profile biographies, profile messages,
avatars, builds, guides, comments, or other user content that violates these terms
or presents a safety, privacy, legal, or abuse risk. Removing content does not
remove related moderation, audit, or legal records when SHD.build must retain them.
</p>
</div>
</section>
<section id="conduct">
@@ -93,9 +99,23 @@
We may process internal account identifiers, private login username, public display
name, password hash, verified email, linked-provider identifiers and profile
details, passkey public credentials, avatar, roles, permissions, builds, guides,
favorites, ratings, comments, notification messages, read state, and delivery
preferences. SHD.build stores passwords only as salted Argon2id hashes. Private
passkey material remains on your authenticator.
public biography, profile visibility, public authored-content summaries, profile
messages, favorites, ratings, comments, notification messages, read state, and
delivery preferences. SHD.build stores passwords only as salted Argon2id hashes.
Private passkey material remains on your authenticator.
</p>
<p>
A public Agent profile displays your public display name, avatar, biography, public
builds, published guides, recent public discussion, and profile messages. Other
signed-in users may post messages on a public profile when their account has
permission to do so. Profile messages display the author's public identity and may
be removed by their author or Community Oversight.
</p>
<p>
You may hide your Agent profile from public discovery and prevent new profile
messages. Hiding a profile does not remove your display name or avatar from public
builds, guides, or comments you authored. It also does not make existing public
content anonymous.
</p>
<p>
For login security and support, we may record the time, success status,
@@ -198,9 +218,11 @@
When you delete your account, SHD.build removes active sessions, credentials,
verified email, provider profiles, passkeys, permissions, and uploaded avatar. You
may also select an immediate purge of retained login-event history during
self-deletion. SHD.build may keep public contributions under a “Deleted Agent”
attribution. SHD.build may retain backups and records for a limited period where
applicable when fraud prevention, dispute resolution, or law requires them.
self-deletion. Account deletion removes the public biography and makes the Agent
profile unavailable. SHD.build may keep public builds, guides, and comments under
a “Deleted Agent” attribution so community references remain intact. SHD.build may
retain backups and records for a limited period when fraud prevention, dispute
resolution, or law requires them.
</p>
</div>
</section>