mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
6422324edd
* feat(web): Button primitive + dark text-on-fill AA fix; migrate 95 button sites (TASK-2292) PLAN-2290 Phase 2, PR 2. lib/components/common/Button.svelte — variants primary (filled --accent-primary-strong #7c4ff0, the violet band where white text passes AA 4.96:1 while staying >=3:1 vs surface — pays off the PR #1018 deferral) / secondary / ghost / danger (red tint, AA both themes); size sm/md; full attr passthrough (type=submit preserved at form sites). 95 usages across 14 files migrated (settings, conventions, playbooks x2, workspace home, console suite, modals, comment composer, EmptyState); dead scoped .btn* CSS deleted (net -291 lines). Deliberate leave-alones per file: ItemDetail action-bar strip (Phase 4 owns the pane), anchors styled as buttons, segmented controls, icon-only buttons, dashed low-emphasis affordances. Gates: svelte-check 0 errors (dead-selector warnings down 8->7), 488 web tests, make check green; screenshots reviewed both themes. * fix(web): Button class-prop merge + danger-solid variant for final confirms Codex findings on #1020: (1) caller-supplied class no longer clobbers the primitive's classes — class is destructured and merged, rest spread moved first; (2) new danger-solid variant (filled --accent-red-strong #dc2626, white text 4.83:1 AA both themes) restores destructive emphasis on the two final-confirm flows that had gone pale (OpenChildrenDialog override, conventions delete Confirm); entry-level destructive buttons keep the tint.
439 lines
10 KiB
Svelte
439 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import { api } from '$lib/api/client';
|
|
import { sseService } from '$lib/services/sse.svelte';
|
|
import { relativeTime, renderMarkdown } from '$lib/utils/markdown';
|
|
import type { Comment, Item } from '$lib/types';
|
|
import Button from '$lib/components/common/Button.svelte';
|
|
|
|
interface Props {
|
|
wsSlug: string;
|
|
username?: string;
|
|
itemSlug: string;
|
|
items?: Item[];
|
|
}
|
|
|
|
let { wsSlug, username = '', itemSlug, items = [] }: Props = $props();
|
|
|
|
let comments = $state<Comment[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let newBody = $state('');
|
|
let submitting = $state(false);
|
|
let deletingId = $state<string | null>(null);
|
|
|
|
async function loadComments() {
|
|
try {
|
|
const result = await api.comments.list(wsSlug, itemSlug);
|
|
comments = result.sort(
|
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
);
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : 'Failed to load comments';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
const body = newBody.trim();
|
|
if (!body || submitting) return;
|
|
|
|
submitting = true;
|
|
try {
|
|
await api.comments.create(wsSlug, itemSlug, {
|
|
body,
|
|
created_by: 'user',
|
|
source: 'web'
|
|
});
|
|
newBody = '';
|
|
await loadComments();
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : 'Failed to post comment';
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
async function handleDelete(commentId: string) {
|
|
if (!confirm('Delete this comment?')) return;
|
|
|
|
deletingId = commentId;
|
|
try {
|
|
await api.comments.delete(wsSlug, commentId);
|
|
await loadComments();
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : 'Failed to delete comment';
|
|
} finally {
|
|
deletingId = null;
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}
|
|
|
|
function authorLabel(createdBy: string): string {
|
|
return createdBy === 'agent' ? 'Agent' : 'User';
|
|
}
|
|
|
|
function sourceLabel(source: string): string {
|
|
const labels: Record<string, string> = {
|
|
cli: 'CLI',
|
|
web: 'Web',
|
|
skill: 'Skill'
|
|
};
|
|
return labels[source] ?? source;
|
|
}
|
|
|
|
$effect(() => {
|
|
// Re-fetch when workspace or item changes
|
|
void wsSlug;
|
|
void itemSlug;
|
|
loading = true;
|
|
error = '';
|
|
loadComments();
|
|
});
|
|
|
|
const unsubscribe = sseService.onItemEvent((event) => {
|
|
if (
|
|
(event.type === 'comment_created' || event.type === 'comment_deleted') &&
|
|
event.item_id
|
|
) {
|
|
loadComments();
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
unsubscribe();
|
|
});
|
|
</script>
|
|
|
|
<div class="comment-thread">
|
|
<div class="thread-header">
|
|
<h4>Comments <span class="count">({comments.length})</span></h4>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="loading">
|
|
<span class="spinner"></span>
|
|
<span>Loading comments...</span>
|
|
</div>
|
|
{:else if error}
|
|
<div class="error-msg">{error}</div>
|
|
{:else}
|
|
<div class="comments-list">
|
|
{#each comments as comment (comment.id)}
|
|
<div class="comment" class:deleting={deletingId === comment.id}>
|
|
<div class="comment-meta">
|
|
<span
|
|
class="author-badge"
|
|
class:author-user={comment.created_by !== 'agent'}
|
|
class:author-agent={comment.created_by === 'agent'}
|
|
>
|
|
{authorLabel(comment.created_by)}
|
|
</span>
|
|
<span class="source">{sourceLabel(comment.source)}</span>
|
|
<span class="timestamp" title={new Date(comment.created_at).toLocaleString()}>{relativeTime(comment.created_at)}</span>
|
|
<button
|
|
class="delete-btn"
|
|
type="button"
|
|
title="Delete comment"
|
|
disabled={deletingId === comment.id}
|
|
onclick={() => handleDelete(comment.id)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div class="comment-body prose">
|
|
{@html renderMarkdown(comment.body, items, wsSlug, username)}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<p class="empty">No comments yet.</p>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<form class="compose" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
|
<textarea
|
|
class="compose-input"
|
|
placeholder="Write a comment..."
|
|
rows="3"
|
|
bind:value={newBody}
|
|
onkeydown={handleKeydown}
|
|
disabled={submitting}
|
|
></textarea>
|
|
<div class="compose-actions">
|
|
<span class="hint">Ctrl+Enter to submit</span>
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
disabled={submitting || !newBody.trim()}
|
|
>
|
|
{submitting ? 'Posting...' : 'Comment'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<style>
|
|
/* ── Container ──────────────────────────────────────────────────────────── */
|
|
|
|
.comment-thread {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
/* ── Header ────────────────────────────────────────────────────────────── */
|
|
|
|
.thread-header {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.thread-header h4 {
|
|
margin: 0;
|
|
font-size: 0.95em;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.count {
|
|
font-weight: 400;
|
|
color: var(--text-muted);
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
/* ── Loading ────────────────────────────────────────────────────────────── */
|
|
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
padding: var(--space-4) 0;
|
|
color: var(--text-muted);
|
|
font-size: 0.9em;
|
|
justify-content: center;
|
|
}
|
|
|
|
.spinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent-blue);
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* ── Error ──────────────────────────────────────────────────────────────── */
|
|
|
|
.error-msg {
|
|
padding: var(--space-2) var(--space-3);
|
|
background: color-mix(in srgb, var(--accent-red) 12%, transparent);
|
|
color: var(--accent-red);
|
|
border-radius: var(--radius);
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
/* ── Comments list ─────────────────────────────────────────────────────── */
|
|
|
|
.comments-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.comment {
|
|
padding: var(--space-3) 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.comment:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.comment.deleting {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.empty {
|
|
color: var(--text-muted);
|
|
font-size: 0.9em;
|
|
margin: 0;
|
|
padding: var(--space-3) 0;
|
|
}
|
|
|
|
/* ── Comment meta ──────────────────────────────────────────────────────── */
|
|
|
|
.comment-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin-bottom: var(--space-2);
|
|
}
|
|
|
|
.author-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 1px var(--space-2);
|
|
border-radius: 9999px;
|
|
font-size: 0.7em;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.author-user {
|
|
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
|
|
color: var(--accent-blue);
|
|
}
|
|
|
|
.author-agent {
|
|
background: color-mix(in srgb, var(--accent-purple, #a855f7) 15%, transparent);
|
|
color: var(--accent-purple, #a855f7);
|
|
}
|
|
|
|
.source {
|
|
font-size: 0.75em;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.timestamp {
|
|
font-size: 0.75em;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.delete-btn {
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
font-size: 0.75em;
|
|
cursor: pointer;
|
|
padding: var(--space-1);
|
|
border-radius: var(--radius-sm);
|
|
line-height: 1;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.comment:hover .delete-btn {
|
|
opacity: 1;
|
|
}
|
|
|
|
.delete-btn:hover {
|
|
color: var(--accent-red);
|
|
background: color-mix(in srgb, var(--accent-red) 10%, transparent);
|
|
}
|
|
|
|
.delete-btn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* ── Comment body ──────────────────────────────────────────────────────── */
|
|
|
|
.comment-body {
|
|
font-size: 0.9em;
|
|
color: var(--text-primary);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.comment-body :global(p) {
|
|
margin: 0 0 var(--space-2) 0;
|
|
}
|
|
|
|
.comment-body :global(p:last-child) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.comment-body :global(code) {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.9em;
|
|
padding: 1px var(--space-1);
|
|
background: var(--bg-tertiary);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.comment-body :global(pre) {
|
|
background: var(--bg-tertiary);
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius);
|
|
overflow-x: auto;
|
|
margin: var(--space-2) 0;
|
|
}
|
|
|
|
.comment-body :global(pre code) {
|
|
padding: 0;
|
|
background: none;
|
|
}
|
|
|
|
.comment-body :global(a) {
|
|
color: var(--accent-blue);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.comment-body :global(a:hover) {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ── Compose ────────────────────────────────────────────────────────────── */
|
|
|
|
.compose {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
padding-top: var(--space-2);
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.compose-input {
|
|
width: 100%;
|
|
padding: var(--space-2) var(--space-3);
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
color: var(--text-primary);
|
|
font-size: 0.9em;
|
|
font-family: inherit;
|
|
line-height: 1.5;
|
|
resize: vertical;
|
|
min-height: 60px;
|
|
}
|
|
|
|
.compose-input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.compose-input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-blue);
|
|
}
|
|
|
|
.compose-input:disabled {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.compose-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.hint {
|
|
font-size: 0.75em;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
</style>
|