Add public agent profiles and community moderation

This commit is contained in:
JB
2026-07-27 15:38:27 -04:00
parent efb0ccdc7e
commit 6b76110b76
28 changed files with 2101 additions and 64 deletions
+229
View File
@@ -0,0 +1,229 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
type PublicAgent = {
id: string;
displayName: string;
avatarUrl: string;
bioHtml: string;
};
type PublicContent = {
id: string;
slug: string;
title: string;
summary: string | null;
updatedAt: string;
};
type AuthoredComment = {
id: string;
bodyHtml: string;
createdAt: string;
targetType: string;
targetTitle: string;
targetHref: string;
};
type ProfileComment = {
id: string;
bodyHtml: string;
createdAt: string;
author: { id: string; displayName: string; avatarUrl: string };
canDelete: boolean;
};
type PageData = {
agent: PublicAgent;
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
builds: PublicContent[];
guides: PublicContent[];
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; isSelf: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let profileCommentDraft = $state('');
let deletingCommentId = $state('');
let handledCommentToken = $state('');
const dateLabel = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.valueOf())
? 'DATE UNKNOWN'
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
};
$effect(() => {
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
if (!token || token === handledCommentToken) return;
handledCommentToken = token;
if (form?.profileCommentSaved || form?.commentSaved) profileCommentDraft = '';
if (form?.profileCommentDeleted || form?.commentDeleted) deletingCommentId = '';
});
</script>
<svelte:head>
<title>{data.agent.displayName} — Agent Profile — SHD.build</title>
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, guides, and community activity on SHD.build.`}/>
</svelte:head>
<main class="page public-agent-page">
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
<header class="agent-command">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<div class="agent-command-actions">
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
</div>
</header>
<section class="agent-telemetry" aria-label="Agent content totals">
<div><span>PUBLIC BUILDS</span><strong>{data.counts.builds}</strong></div>
<div><span>PUBLISHED GUIDES</span><strong>{data.counts.guides}</strong></div>
<div><span>CONTENT COMMENTS</span><strong>{data.counts.authoredComments}</strong></div>
<div><span>PROFILE MESSAGES</span><strong>{data.counts.profileComments}</strong></div>
</section>
<div class="agent-profile-grid">
<div class="agent-primary">
<section class="profile-section agent-bio">
<header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><span>PUBLIC BIO</span></header>
<div class="bio-content">
<MarkdownContent html={data.agent.bioHtml} emptyMessage="This Agent has not published a field bio yet." />
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
<div class="public-content-grid">
{#each data.builds as build}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}>
<span>LOADOUT</span>
<h3>{build.title}</h3>
<p>{build.summary || 'Open this loadout to inspect its equipment, Attributes, and calculated performance.'}</p>
<footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC BUILDS</strong>
<p>This Agent has not shared a public loadout.</p>
</div>
{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">FIELD GUIDANCE</p><h2>PUBLISHED GUIDES</h2></div><span>{data.counts.guides} TOTAL</span></header>
<div class="public-content-grid">
{#each data.guides as guide}
<a class="public-content-card guide-card" href={`/builds/${encodeURIComponent(guide.slug)}`}>
<span>GUIDE</span>
<h3>{guide.title}</h3>
<p>{guide.summary || 'Open this guide for the complete strategy and field notes.'}</p>
<footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLISHED GUIDES</strong>
<p>This Agent has not published a guide.</p>
</div>
{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">COMMUNITY RECORD</p><h2>RECENT DISCUSSION</h2></div><span>{data.counts.authoredComments} COMMENTS</span></header>
<div class="authored-comment-list">
{#each data.authoredComments as comment}
<article>
<header><div><span>{comment.targetType.toUpperCase()}</span><strong>{comment.targetTitle}</strong></div><small>{dateLabel(comment.createdAt)}</small></header>
<MarkdownContent html={comment.bodyHtml} compact />
<a href={comment.targetHref}>OPEN DISCUSSION →</a>
</article>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC DISCUSSION</strong>
<p>Comments this Agent leaves on builds and guides will appear here.</p>
</div>
{/each}
</div>
</section>
</div>
<aside class="profile-section profile-comms">
<header><div><p class="eyebrow">AGENT COMMS</p><h2>PROFILE MESSAGES</h2></div><span>{data.counts.profileComments}</span></header>
{#if data.viewer.canComment}
<PendingForm action="?/commentProfile" class="profile-comment-form">
<input type="hidden" name="agentId" value={data.agent.id}/>
<MarkdownEditor
name="body"
label="LEAVE A MESSAGE"
help="Keep profile messages relevant, respectful, and useful to the Agent."
maxLength={2000}
rows={6}
required
bind:value={profileCommentDraft}
/>
{#if form?.profileCommentError || form?.commentError}<p class="error">{form?.profileCommentError ?? form?.commentError}</p>{/if}
{#if form?.profileCommentSaved || form?.commentSaved}<p class="success">Profile message transmitted.</p>{/if}
<button class="primary" disabled={!profileCommentDraft.trim()}>POST MESSAGE →</button>
</PendingForm>
{:else if !data.viewer.id}
<div class="comment-access-note"><strong>SIGN-IN REQUIRED</strong><p>Sign in to leave a message on this Agent profile.</p><a class="secondary" href="/sign-in">SIGN IN →</a></div>
{:else}
<div class="comment-access-note"><strong>MESSAGING UNAVAILABLE</strong><p>Your account cannot post profile messages at this time.</p></div>
{/if}
<div class="profile-comment-list">
{#each data.profileComments as comment}
<article>
<header>
<AgentIdentityLink agent={comment.author} size="compact" meta={dateLabel(comment.createdAt)} />
{#if comment.canDelete}
<button type="button" class="comment-remove" aria-expanded={deletingCommentId === comment.id} onclick={() => deletingCommentId = comment.id}>REMOVE</button>
{/if}
</header>
<MarkdownContent html={comment.bodyHtml} compact />
{#if deletingCommentId === comment.id}
<div class="comment-delete-confirm" role="alert">
<p>Remove this profile message?</p>
<div>
<button type="button" class="secondary" onclick={() => deletingCommentId = ''}>CANCEL</button>
<PendingForm action="?/deleteProfileComment">
<input type="hidden" name="commentId" value={comment.id}/>
<button class="primary">CONFIRM REMOVE</button>
</PendingForm>
</div>
</div>
{/if}
</article>
{:else}
<div class="public-empty compact">
<span>◇</span><strong>NO PROFILE MESSAGES</strong>
<p>Be the first Agent to leave a useful field message.</p>
</div>
{/each}
</div>
</aside>
</div>
</main>
<style>
.public-agent-page{padding-top:28px}.agent-command{min-height:116px;display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-lg);margin-top:12px;padding:20px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.agent-command :global(.agent-identity){flex:1}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
.agent-telemetry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:8px;border:1px solid var(--shd-border);background:var(--shd-surface)}
.agent-telemetry>div{display:grid;gap:4px;padding:10px 12px;border-left:1px solid var(--shd-border)}.agent-telemetry>div:first-child{border-left:0}.agent-telemetry span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.agent-telemetry strong{color:var(--shd-orange);font-size:24px}
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(330px,.38fr);gap:10px;margin-top:10px}.agent-primary{display:grid;gap:10px;min-width:0}
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.bio-content{min-height:110px;padding:18px}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card.guide-card{border-left-color:var(--shd-info)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.guide-card>span{color:var(--shd-info)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{display:flex;justify-content:space-between;gap:10px}.authored-comment-list article>header>div{display:grid;gap:3px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{color:var(--shd-text-muted)}.authored-comment-list article>a{justify-self:end}
.profile-comms{align-self:start;position:sticky;top:88px}:global(form.profile-comment-form){display:grid;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border)}:global(form.profile-comment-form > button){min-height:40px}.profile-comment-list{display:grid}.profile-comment-list>article{display:grid;gap:9px;padding:10px;border-bottom:1px solid var(--shd-border)}.profile-comment-list>article:last-child{border-bottom:0}.profile-comment-list article>header{display:flex;align-items:center;justify-content:space-between;gap:8px}.profile-comment-list article>header :global(.agent-identity){flex:1}.comment-remove{min-height:32px;padding:0 7px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.comment-remove:hover,.comment-remove:focus-visible{background:var(--shd-danger);color:var(--shd-bg)}.comment-delete-confirm{display:grid;gap:8px;padding:8px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{display:grid;gap:7px;padding:12px;border-bottom:1px solid var(--shd-border)}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.profile-comms{position:static}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{align-items:stretch;flex-direction:column}.agent-command-actions a{width:100%;min-height:44px;justify-content:center}.agent-telemetry{grid-template-columns:1fr}.agent-telemetry>div{border-top:1px solid var(--shd-border);border-left:0}.agent-telemetry>div:first-child{border-top:0}.public-content-grid{grid-template-columns:1fr}.profile-section>header{align-items:flex-start;flex-direction:column}:global(form.profile-comment-form > button),.comment-delete-confirm button{min-height:44px}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.comment-remove{transition:none}}
</style>