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
@@ -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();
});
});