Add public agent profiles and community moderation
This commit is contained in:
+1
-1
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,114 @@
|
||||
const escapeHtml = (value: string) => value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
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('&', '&')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('"', '"');
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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('<script>');
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
|
||||
)
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user