Add public agent profiles and community moderation
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user