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