visual-comments 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +477 -0
- package/dist/core/aim.d.ts +21 -0
- package/dist/core/aim.js +21 -0
- package/dist/core/api.d.ts +5 -0
- package/dist/core/api.js +11 -0
- package/dist/core/metaball.d.ts +15 -0
- package/dist/core/metaball.js +42 -0
- package/dist/core/presence.d.ts +13 -0
- package/dist/core/presence.js +25 -0
- package/dist/core/surfaces.d.ts +95 -0
- package/dist/core/surfaces.js +197 -0
- package/dist/core/target.d.ts +59 -0
- package/dist/core/target.js +382 -0
- package/dist/core/threads.svelte.d.ts +80 -0
- package/dist/core/threads.svelte.js +336 -0
- package/dist/core/time.d.ts +3 -0
- package/dist/core/time.js +25 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/server/env.d.ts +78 -0
- package/dist/server/env.js +94 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.js +74 -0
- package/dist/server/variables.d.ts +28 -0
- package/dist/server/variables.js +73 -0
- package/dist/shared/limits.d.ts +9 -0
- package/dist/shared/limits.js +10 -0
- package/dist/shared/protocol.d.ts +6 -0
- package/dist/shared/protocol.js +7 -0
- package/dist/shared/types.d.ts +157 -0
- package/dist/shared/types.js +2 -0
- package/dist/sveltekit/CommentTools.svelte +98 -0
- package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
- package/dist/sveltekit/Comments.svelte +104 -0
- package/dist/sveltekit/Comments.svelte.d.ts +25 -0
- package/dist/sveltekit/Toolbar.svelte +64 -0
- package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
- package/dist/sveltekit/ToolbarButton.svelte +66 -0
- package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
- package/dist/sveltekit/context.d.ts +16 -0
- package/dist/sveltekit/context.js +9 -0
- package/dist/sveltekit/index.d.ts +6 -0
- package/dist/sveltekit/index.js +6 -0
- package/dist/ui/Account.svelte +106 -0
- package/dist/ui/Account.svelte.d.ts +8 -0
- package/dist/ui/App.svelte +77 -0
- package/dist/ui/App.svelte.d.ts +7 -0
- package/dist/ui/Avatar.svelte +64 -0
- package/dist/ui/Avatar.svelte.d.ts +11 -0
- package/dist/ui/Composer.svelte +267 -0
- package/dist/ui/Composer.svelte.d.ts +20 -0
- package/dist/ui/Connect.svelte +128 -0
- package/dist/ui/Connect.svelte.d.ts +6 -0
- package/dist/ui/Icon.svelte +44 -0
- package/dist/ui/Icon.svelte.d.ts +29 -0
- package/dist/ui/Menu.svelte +69 -0
- package/dist/ui/Menu.svelte.d.ts +15 -0
- package/dist/ui/MenuItem.svelte +38 -0
- package/dist/ui/MenuItem.svelte.d.ts +12 -0
- package/dist/ui/Overlay.svelte +502 -0
- package/dist/ui/Overlay.svelte.d.ts +3 -0
- package/dist/ui/Panel.svelte +220 -0
- package/dist/ui/Panel.svelte.d.ts +10 -0
- package/dist/ui/Pin.svelte +156 -0
- package/dist/ui/Pin.svelte.d.ts +15 -0
- package/dist/ui/Presence.svelte +43 -0
- package/dist/ui/Presence.svelte.d.ts +8 -0
- package/dist/ui/ThreadCard.svelte +201 -0
- package/dist/ui/ThreadCard.svelte.d.ts +13 -0
- package/dist/ui/Toolbar.svelte +35 -0
- package/dist/ui/Toolbar.svelte.d.ts +18 -0
- package/dist/ui/context.d.ts +3 -0
- package/dist/ui/context.js +8 -0
- package/dist/ui/generated/styles.css +2 -0
- package/dist/ui/generated/styles.d.ts +2 -0
- package/dist/ui/generated/styles.js +3 -0
- package/dist/ui/styles.css +113 -0
- package/dist/ui/text.d.ts +5 -0
- package/dist/ui/text.js +11 -0
- package/dist/visual-comments.svelte.d.ts +230 -0
- package/dist/visual-comments.svelte.js +693 -0
- package/dist/vite/index.d.ts +20 -0
- package/dist/vite/index.js +78 -0
- package/dist-widget/visual-comments.js +58 -0
- package/package.json +86 -3
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<!-- The threads of this page, or of every page. Choosing one shows it where it was placed. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { fly } from 'svelte/transition';
|
|
4
|
+
import { useComments } from './context.js';
|
|
5
|
+
import Avatar from './Avatar.svelte';
|
|
6
|
+
import Icon from './Icon.svelte';
|
|
7
|
+
import Menu from './Menu.svelte';
|
|
8
|
+
import MenuItem from './MenuItem.svelte';
|
|
9
|
+
import { commentText } from './text.js';
|
|
10
|
+
import { ago, fullDate } from '../core/time.js';
|
|
11
|
+
import type { CommentThread } from '../shared/types.js';
|
|
12
|
+
|
|
13
|
+
let { onselect, onclose, class: className = '', style = '' }: {
|
|
14
|
+
onselect: (thread: CommentThread) => void;
|
|
15
|
+
onclose: () => void;
|
|
16
|
+
class?: string;
|
|
17
|
+
style?: string;
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
const vc = useComments();
|
|
21
|
+
const comments = vc.store;
|
|
22
|
+
const filters = $derived(vc.filters);
|
|
23
|
+
const viewer = $derived(vc.viewer);
|
|
24
|
+
|
|
25
|
+
const lastActivity = (thread: CommentThread) => thread.comments.at(-1)?.createdAt ?? thread.createdAt;
|
|
26
|
+
/** Threads on other pages, which the list shows only when asked. */
|
|
27
|
+
const elsewhere = $derived(comments.threads.filter((t) => t.page !== vc.page).length);
|
|
28
|
+
const here = $derived(comments.threads.filter((t) => filters.allPages || t.page === vc.page));
|
|
29
|
+
const resolvedCount = $derived(here.filter((t) => t.resolvedAt).length);
|
|
30
|
+
|
|
31
|
+
const visible = $derived.by(() => {
|
|
32
|
+
const query = filters.query.trim().toLocaleLowerCase();
|
|
33
|
+
const matches = (thread: CommentThread) =>
|
|
34
|
+
!query ||
|
|
35
|
+
`#${thread.id}` === query ||
|
|
36
|
+
vc.titleOf(thread).toLocaleLowerCase().includes(query) ||
|
|
37
|
+
thread.comments.some((c) =>
|
|
38
|
+
c.body.toLocaleLowerCase().includes(query) || c.author.name.toLocaleLowerCase().includes(query));
|
|
39
|
+
const list = here.filter((thread) =>
|
|
40
|
+
(filters.showResolved || !thread.resolvedAt) &&
|
|
41
|
+
(!filters.onlyMine || thread.comments.some((c) => c.author.sub === viewer?.sub)) &&
|
|
42
|
+
matches(thread));
|
|
43
|
+
// By page, then by the surface's place on it.
|
|
44
|
+
const order = (thread: CommentThread) => vc.orderOf(thread);
|
|
45
|
+
return filters.sort === 'place'
|
|
46
|
+
? list.sort((a, b) => a.page.localeCompare(b.page) || order(a) - order(b) || a.id - b.id)
|
|
47
|
+
: list.sort((a, b) => lastActivity(b) - lastActivity(a));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function copyLink(thread: CommentThread) {
|
|
51
|
+
void navigator.clipboard?.writeText(vc.linkOf(thread));
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<aside
|
|
56
|
+
data-vc-ui
|
|
57
|
+
aria-label="Comments"
|
|
58
|
+
class={['pointer-events-auto flex min-h-0 flex-col overflow-hidden rounded border border-vc-line bg-white text-[13px] text-vc-ink shadow-[0_1px_3px_rgb(0_0_0/0.12)]', className]}
|
|
59
|
+
{style}
|
|
60
|
+
transition:fly={{ x: 16, duration: 160 }}
|
|
61
|
+
>
|
|
62
|
+
<header class="flex h-11 shrink-0 items-center gap-0.5 pr-1.5 pl-3.5">
|
|
63
|
+
<h2 class="min-w-0 flex-1 truncate font-semibold" title={comments.spaceName || undefined}>Comments</h2>
|
|
64
|
+
{#if !vc.panelCollapsed}
|
|
65
|
+
<Menu label="Filter and sort" align="end" class="vc-tool-light size-7 rounded-full">
|
|
66
|
+
{#snippet trigger()}<Icon name="filter" />{/snippet}
|
|
67
|
+
{#snippet children(close)}
|
|
68
|
+
<MenuItem checked={filters.sort === 'date'} onselect={() => { close(); filters.sort = 'date'; }}>Sort by date</MenuItem>
|
|
69
|
+
<MenuItem checked={filters.sort === 'place'} onselect={() => { close(); filters.sort = 'place'; }}>Sort by place</MenuItem>
|
|
70
|
+
<div class="my-1 border-t border-vc-line"></div>
|
|
71
|
+
{#if elsewhere || filters.allPages}
|
|
72
|
+
<MenuItem checked={filters.allPages} onselect={() => { close(); filters.allPages = !filters.allPages; }}>
|
|
73
|
+
Show every page
|
|
74
|
+
</MenuItem>
|
|
75
|
+
{/if}
|
|
76
|
+
<MenuItem checked={filters.showResolved} onselect={() => { close(); filters.showResolved = !filters.showResolved; }}>
|
|
77
|
+
Show resolved comments
|
|
78
|
+
</MenuItem>
|
|
79
|
+
{#if viewer}
|
|
80
|
+
<MenuItem checked={filters.onlyMine} onselect={() => { close(); filters.onlyMine = !filters.onlyMine; }}>
|
|
81
|
+
Only your threads
|
|
82
|
+
</MenuItem>
|
|
83
|
+
<div class="my-1 border-t border-vc-line"></div>
|
|
84
|
+
<MenuItem checked={false} onselect={() => { close(); void comments.markAllRead(); }}>Mark all as read</MenuItem>
|
|
85
|
+
{/if}
|
|
86
|
+
{/snippet}
|
|
87
|
+
</Menu>
|
|
88
|
+
{/if}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class="vc-tool-light size-7 rounded-full"
|
|
92
|
+
title={vc.panelCollapsed ? 'Expand comments' : 'Collapse comments'}
|
|
93
|
+
aria-label={vc.panelCollapsed ? 'Expand comments' : 'Collapse comments'}
|
|
94
|
+
aria-expanded={!vc.panelCollapsed}
|
|
95
|
+
onclick={() => (vc.panelCollapsed = !vc.panelCollapsed)}
|
|
96
|
+
>
|
|
97
|
+
<Icon name={vc.panelCollapsed ? 'chevron-left' : 'chevron-right'} />
|
|
98
|
+
</button>
|
|
99
|
+
<button type="button" class="vc-tool-light size-7 rounded-full" title="Close comments (C)" aria-label="Close comments" onclick={onclose}>
|
|
100
|
+
<Icon name="close" />
|
|
101
|
+
</button>
|
|
102
|
+
</header>
|
|
103
|
+
|
|
104
|
+
{#if !vc.panelCollapsed}
|
|
105
|
+
<div class="shrink-0 border-b border-vc-line px-2.5 pb-2.5">
|
|
106
|
+
<label class="flex h-8 items-center gap-2 rounded-[6px] bg-vc-surface px-2.5 text-vc-muted focus-within:ring-1 focus-within:ring-vc-comment">
|
|
107
|
+
<Icon name="search" size={15} />
|
|
108
|
+
<input
|
|
109
|
+
type="search"
|
|
110
|
+
placeholder="Search"
|
|
111
|
+
aria-label="Search comments"
|
|
112
|
+
bind:value={filters.query}
|
|
113
|
+
onkeydown={(event) => { if (event.key === 'Escape') { filters.query = ''; (event.target as HTMLElement).blur(); } }}
|
|
114
|
+
class="min-w-0 flex-1 bg-transparent text-vc-ink outline-none placeholder:text-vc-muted/80"
|
|
115
|
+
/>
|
|
116
|
+
</label>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{#if !viewer && !comments.loading}
|
|
120
|
+
<div class="shrink-0 border-b border-vc-line px-3.5 py-3">
|
|
121
|
+
<p>Sign in to see comments and add your own.</p>
|
|
122
|
+
<button type="button" class="mt-2 h-7 rounded-full bg-vc-comment px-3 font-medium text-white hover:brightness-110"
|
|
123
|
+
onclick={() => vc.signIn()}>Sign in</button>
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
|
|
127
|
+
<ol class="min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain p-2">
|
|
128
|
+
{#each visible as thread (thread.id)}
|
|
129
|
+
{@const root = thread.comments[0]}
|
|
130
|
+
{@const replies = thread.comments.length - 1}
|
|
131
|
+
{@const selected = thread.id === vc.openId}
|
|
132
|
+
<li class={['group relative rounded-[8px] px-3 py-2.5', selected ? 'bg-vc-comment-soft' : 'hover:bg-vc-surface']}>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
class="absolute inset-0 rounded-[8px] focus-visible:outline-2 focus-visible:outline-vc-comment"
|
|
136
|
+
aria-label="Show comment #{thread.id} on {vc.titleOf(thread)}"
|
|
137
|
+
aria-current={selected || undefined}
|
|
138
|
+
onclick={() => onselect(thread)}
|
|
139
|
+
></button>
|
|
140
|
+
<div class={['pointer-events-none relative', thread.resolvedAt && 'opacity-55']}>
|
|
141
|
+
<div class="flex h-7 items-center">
|
|
142
|
+
{#if thread.unread}
|
|
143
|
+
<span class="absolute top-3 -left-2 size-1.5 rounded-full bg-vc-comment" title="Unread"></span>
|
|
144
|
+
{/if}
|
|
145
|
+
<Avatar user={root.author} size={28} />
|
|
146
|
+
</div>
|
|
147
|
+
<p class="mt-1.5 truncate text-vc-muted">#{thread.id} · {vc.titleOf(thread)}</p>
|
|
148
|
+
<p class="truncate">
|
|
149
|
+
<span class="font-medium">{root.author.name}</span>
|
|
150
|
+
<time class="ml-1 text-vc-muted" title={fullDate(root.createdAt)}>{ago(root.createdAt, comments.now)}</time>
|
|
151
|
+
</p>
|
|
152
|
+
<p class={['line-clamp-3 break-words whitespace-pre-line', thread.unread && 'font-medium']}>{commentText(root)}</p>
|
|
153
|
+
{#if replies > 0}
|
|
154
|
+
<p class="mt-1 text-xs text-vc-muted">
|
|
155
|
+
{replies} {replies === 1 ? 'reply' : 'replies'}
|
|
156
|
+
</p>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
159
|
+
{#if viewer}
|
|
160
|
+
<div class="absolute top-2.5 right-2 flex items-center">
|
|
161
|
+
<div class={['transition-opacity', !selected && 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100']}>
|
|
162
|
+
<Menu label="Thread options" align="end" class="vc-tool-light size-7 rounded-full">
|
|
163
|
+
{#snippet trigger()}<Icon name="more" />{/snippet}
|
|
164
|
+
{#snippet children(close)}
|
|
165
|
+
<MenuItem onselect={() => { close(); void comments.markRead(thread.id, !thread.unread); }}>
|
|
166
|
+
{thread.unread ? 'Mark as read' : 'Mark as unread'}
|
|
167
|
+
</MenuItem>
|
|
168
|
+
<MenuItem onselect={() => { close(); copyLink(thread); }}>Copy link</MenuItem>
|
|
169
|
+
{#if root.author.sub === viewer.sub}
|
|
170
|
+
<MenuItem onselect={() => {
|
|
171
|
+
close();
|
|
172
|
+
if (confirm('Delete this thread and all its replies?')) void comments.removeThread(thread.id);
|
|
173
|
+
}}>Delete thread…</MenuItem>
|
|
174
|
+
{/if}
|
|
175
|
+
{/snippet}
|
|
176
|
+
</Menu>
|
|
177
|
+
</div>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class={['vc-tool-light size-7 rounded-full', thread.resolvedAt && 'text-vc-comment']}
|
|
181
|
+
aria-pressed={Boolean(thread.resolvedAt)}
|
|
182
|
+
title={thread.resolvedAt ? 'Reopen' : 'Resolve'}
|
|
183
|
+
aria-label={thread.resolvedAt ? `Reopen #${thread.id}` : `Resolve #${thread.id}`}
|
|
184
|
+
onclick={() => comments.resolve(thread.id, !thread.resolvedAt)}
|
|
185
|
+
>
|
|
186
|
+
<Icon name="resolve" size={18} />
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
{/if}
|
|
190
|
+
</li>
|
|
191
|
+
{:else}
|
|
192
|
+
{#if viewer && !comments.loading}
|
|
193
|
+
<li class="px-3 py-6 text-center text-vc-muted">
|
|
194
|
+
{#if filters.query.trim()}
|
|
195
|
+
No comments match “{filters.query.trim()}”.
|
|
196
|
+
{:else if filters.onlyMine}
|
|
197
|
+
You have not started or replied to any threads.
|
|
198
|
+
{:else if resolvedCount && !filters.showResolved}
|
|
199
|
+
Every thread is resolved.
|
|
200
|
+
<button type="button" class="text-vc-comment hover:underline" onclick={() => (filters.showResolved = true)}>
|
|
201
|
+
Show resolved
|
|
202
|
+
</button>
|
|
203
|
+
{:else}
|
|
204
|
+
No comments yet. Click {vc.surfaces.some((s) => s.element) ? 'a frame' : 'anywhere on the page'} to add one.
|
|
205
|
+
{/if}
|
|
206
|
+
</li>
|
|
207
|
+
{/if}
|
|
208
|
+
{/each}
|
|
209
|
+
</ol>
|
|
210
|
+
|
|
211
|
+
{#if comments.error}
|
|
212
|
+
<div role="alert" class="m-2 mt-0 flex shrink-0 items-start gap-2 rounded-[8px] bg-vc-ink px-3 py-2 text-white">
|
|
213
|
+
<p class="flex-1">{comments.error}</p>
|
|
214
|
+
<button type="button" class="text-white/70 hover:text-white" aria-label="Dismiss" onclick={() => (comments.error = null)}>
|
|
215
|
+
<Icon name="close" size={14} />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
{/if}
|
|
219
|
+
{/if}
|
|
220
|
+
</aside>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommentThread } from '../shared/types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
onselect: (thread: CommentThread) => void;
|
|
4
|
+
onclose: () => void;
|
|
5
|
+
class?: string;
|
|
6
|
+
style?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const Panel: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type Panel = ReturnType<typeof Panel>;
|
|
10
|
+
export default Panel;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<svelte:options css="injected" />
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
A thread's marker. Its bottom-left point sits on the spot that was clicked.
|
|
5
|
+
On hover it grows from that point into a preview of the first comment. It
|
|
6
|
+
pops out of that point when it appears (loaded, added, scrolled into view)
|
|
7
|
+
and shrinks back into it when it goes.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts" module>
|
|
10
|
+
import { backOut, cubicIn } from 'svelte/easing';
|
|
11
|
+
|
|
12
|
+
/** Scales from the pin's point (its bottom-left corner), keeping its lift above it. */
|
|
13
|
+
function pop(_node: Element, { duration, easing }: { duration: number; easing: (t: number) => number }) {
|
|
14
|
+
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
15
|
+
return {
|
|
16
|
+
duration: still ? 0 : duration,
|
|
17
|
+
easing,
|
|
18
|
+
css: (t: number) =>
|
|
19
|
+
`transform-origin: 0 100%; transform: translateY(-100%) scale(${0.2 + 0.8 * t}); opacity: ${Math.min(1, t * 2)}`
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script lang="ts">
|
|
25
|
+
import Avatar from './Avatar.svelte';
|
|
26
|
+
import { commentText } from './text.js';
|
|
27
|
+
import { ago } from '../core/time.js';
|
|
28
|
+
import type { CommentThread } from '../shared/types.js';
|
|
29
|
+
|
|
30
|
+
let { thread, left, top, open, now, working, onopen }: {
|
|
31
|
+
thread: CommentThread;
|
|
32
|
+
/** The pin's point, in client pixels. */
|
|
33
|
+
left: number;
|
|
34
|
+
top: number;
|
|
35
|
+
open: boolean;
|
|
36
|
+
now: number;
|
|
37
|
+
/** Set while someone works on the thread: what they say they are doing. The pin pulses meanwhile. */
|
|
38
|
+
working?: string;
|
|
39
|
+
onopen: () => void;
|
|
40
|
+
} = $props();
|
|
41
|
+
|
|
42
|
+
const SIZE = 32;
|
|
43
|
+
const root = $derived(thread.comments[0]);
|
|
44
|
+
const text = $derived(commentText(root));
|
|
45
|
+
const replies = $derived(thread.comments.length - 1);
|
|
46
|
+
|
|
47
|
+
let hovered = $state(false);
|
|
48
|
+
let focused = $state(false);
|
|
49
|
+
let textWidth = $state(0);
|
|
50
|
+
let textHeight = $state(0);
|
|
51
|
+
const expanded = $derived((hovered || focused) && !open && textWidth > 0);
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
{#if working}
|
|
55
|
+
<span
|
|
56
|
+
class={['vc-pin-pulse vc-working pointer-events-none absolute', expanded ? 'z-30' : open ? 'z-20' : 'z-10']}
|
|
57
|
+
style:left="{left}px"
|
|
58
|
+
style:top="{top}px"
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
></span>
|
|
61
|
+
{/if}
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
data-vc-ui
|
|
65
|
+
class={[
|
|
66
|
+
'vc-pin pointer-events-auto absolute block overflow-hidden bg-white text-left outline-none',
|
|
67
|
+
expanded ? 'z-30' : open ? 'z-20' : 'z-10',
|
|
68
|
+
thread.resolvedAt && !expanded && !open && 'opacity-60',
|
|
69
|
+
expanded && 'vc-pin-expanded'
|
|
70
|
+
]}
|
|
71
|
+
style:left="{left}px"
|
|
72
|
+
style:top="{top}px"
|
|
73
|
+
style:width="{expanded ? textWidth + 54 : SIZE}px"
|
|
74
|
+
style:height="{expanded ? Math.max(44, textHeight + 20) : SIZE}px"
|
|
75
|
+
style:border-radius={expanded ? '18px 18px 18px 4px' : `${SIZE / 2}px ${SIZE / 2}px ${SIZE / 2}px 0`}
|
|
76
|
+
style:box-shadow={open
|
|
77
|
+
? '0 0 0 2px var(--color-vc-comment), 0 2px 8px rgb(0 0 0 / 0.25)'
|
|
78
|
+
: expanded
|
|
79
|
+
? '0 4px 18px rgb(0 0 0 / 0.22)'
|
|
80
|
+
: '0 1px 4px rgb(0 0 0 / 0.3)'}
|
|
81
|
+
aria-label="Comment #{thread.id} by {root.author.name}: {text}{working ? `. ${working}` : ''}"
|
|
82
|
+
aria-expanded={open}
|
|
83
|
+
in:pop={{ duration: 240, easing: backOut }}
|
|
84
|
+
out:pop={{ duration: 140, easing: cubicIn }}
|
|
85
|
+
onpointerenter={() => (hovered = true)}
|
|
86
|
+
onpointerleave={() => (hovered = false)}
|
|
87
|
+
onfocus={() => (focused = true)}
|
|
88
|
+
onblur={() => (focused = false)}
|
|
89
|
+
onclick={onopen}
|
|
90
|
+
>
|
|
91
|
+
<Avatar
|
|
92
|
+
user={root.author}
|
|
93
|
+
size={24}
|
|
94
|
+
class="vc-pin-avatar absolute"
|
|
95
|
+
/>
|
|
96
|
+
<span
|
|
97
|
+
class="vc-pin-text absolute top-2.5 left-[42px] block w-max max-w-[240px] text-[13px] leading-[18px] text-vc-ink"
|
|
98
|
+
style:opacity={expanded ? 1 : 0}
|
|
99
|
+
bind:offsetWidth={textWidth}
|
|
100
|
+
bind:offsetHeight={textHeight}
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
>
|
|
103
|
+
<span class="block truncate whitespace-nowrap">
|
|
104
|
+
<span class="font-medium">{root.author.name}</span>
|
|
105
|
+
<span class="ml-1 text-vc-muted">{ago(root.createdAt, now)}</span>
|
|
106
|
+
</span>
|
|
107
|
+
<span class="line-clamp-2 block break-words whitespace-pre-line">{text}</span>
|
|
108
|
+
{#if replies > 0}
|
|
109
|
+
<span class="mt-0.5 block text-xs text-vc-muted">{replies} {replies === 1 ? 'reply' : 'replies'}</span>
|
|
110
|
+
{/if}
|
|
111
|
+
{#if working}
|
|
112
|
+
<span class="mt-0.5 block truncate text-xs text-vc-working">{working}</span>
|
|
113
|
+
{/if}
|
|
114
|
+
</span>
|
|
115
|
+
{#if thread.unread}
|
|
116
|
+
<span class="absolute top-0.5 right-0.5 size-2.5 rounded-full bg-vc-comment ring-2 ring-white"></span>
|
|
117
|
+
{/if}
|
|
118
|
+
</button>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
.vc-pin {
|
|
122
|
+
transform: translateY(-100%);
|
|
123
|
+
transition:
|
|
124
|
+
width 220ms cubic-bezier(0.2, 0.9, 0.3, 1),
|
|
125
|
+
height 220ms cubic-bezier(0.2, 0.9, 0.3, 1),
|
|
126
|
+
border-radius 220ms ease,
|
|
127
|
+
box-shadow 160ms ease,
|
|
128
|
+
opacity 160ms ease;
|
|
129
|
+
}
|
|
130
|
+
.vc-pin :global(.vc-pin-avatar) {
|
|
131
|
+
top: 4px;
|
|
132
|
+
left: 4px;
|
|
133
|
+
transition: top 220ms cubic-bezier(0.2, 0.9, 0.3, 1), left 220ms cubic-bezier(0.2, 0.9, 0.3, 1);
|
|
134
|
+
}
|
|
135
|
+
.vc-pin-expanded :global(.vc-pin-avatar) {
|
|
136
|
+
top: 10px;
|
|
137
|
+
left: 10px;
|
|
138
|
+
}
|
|
139
|
+
/* The collapsed pin's shape, under it: its ring pulses out from the pin's edge. */
|
|
140
|
+
.vc-pin-pulse {
|
|
141
|
+
width: 32px;
|
|
142
|
+
height: 32px;
|
|
143
|
+
border-radius: 16px 16px 16px 0;
|
|
144
|
+
transform: translateY(-100%);
|
|
145
|
+
}
|
|
146
|
+
.vc-pin-text {
|
|
147
|
+
transition: opacity 140ms ease 60ms;
|
|
148
|
+
}
|
|
149
|
+
@media (prefers-reduced-motion: reduce) {
|
|
150
|
+
.vc-pin,
|
|
151
|
+
.vc-pin :global(.vc-pin-avatar),
|
|
152
|
+
.vc-pin-text {
|
|
153
|
+
transition: none;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CommentThread } from '../shared/types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
thread: CommentThread;
|
|
4
|
+
/** The pin's point, in client pixels. */
|
|
5
|
+
left: number;
|
|
6
|
+
top: number;
|
|
7
|
+
open: boolean;
|
|
8
|
+
now: number;
|
|
9
|
+
/** Set while someone works on the thread: what they say they are doing. The pin pulses meanwhile. */
|
|
10
|
+
working?: string;
|
|
11
|
+
onopen: () => void;
|
|
12
|
+
};
|
|
13
|
+
declare const Pin: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type Pin = ReturnType<typeof Pin>;
|
|
15
|
+
export default Pin;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Who else is here: an avatar for each other person and agent present, the
|
|
3
|
+
busiest first within each, beside the account button. Hover for what they do.
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
7
|
+
import { activity, present, type Present } from '../core/presence.js';
|
|
8
|
+
import Avatar from './Avatar.svelte';
|
|
9
|
+
import { authorLabel } from './text.js';
|
|
10
|
+
|
|
11
|
+
let { vc, size = 24 }: { vc: VisualComments; size?: number } = $props();
|
|
12
|
+
|
|
13
|
+
const SHOWN = 4;
|
|
14
|
+
const others = $derived(present(vc.participants.filter((p) => p.author.sub !== vc.viewer?.sub)));
|
|
15
|
+
const shown = $derived(others.length > SHOWN ? others.slice(0, SHOWN - 1) : others);
|
|
16
|
+
const rest = $derived(others.slice(shown.length));
|
|
17
|
+
|
|
18
|
+
function describe(someone: Present) {
|
|
19
|
+
const doing = someone.participants.map(activity).filter(Boolean);
|
|
20
|
+
return [authorLabel(someone.author), ...doing].join('\n');
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if others.length}
|
|
25
|
+
<ul class="flex items-center gap-1 px-1" aria-label="Here now">
|
|
26
|
+
{#each shown as someone (someone.author.sub)}
|
|
27
|
+
<li class="flex" aria-label={describe(someone)}>
|
|
28
|
+
<Avatar user={someone.author} {size} title={describe(someone)} class={someone.state === 'working' ? 'vc-working' : ''} />
|
|
29
|
+
</li>
|
|
30
|
+
{/each}
|
|
31
|
+
{#if rest.length}
|
|
32
|
+
<li
|
|
33
|
+
class="flex items-center justify-center rounded-full bg-current/15 text-[11px] font-medium"
|
|
34
|
+
style:width="{size}px"
|
|
35
|
+
style:height="{size}px"
|
|
36
|
+
title={rest.map(describe).join('\n\n')}
|
|
37
|
+
aria-label={rest.map(describe).join('\n\n')}
|
|
38
|
+
>
|
|
39
|
+
+{rest.length}
|
|
40
|
+
</li>
|
|
41
|
+
{/if}
|
|
42
|
+
</ul>
|
|
43
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
vc: VisualComments;
|
|
4
|
+
size?: number;
|
|
5
|
+
};
|
|
6
|
+
declare const Presence: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type Presence = ReturnType<typeof Presence>;
|
|
8
|
+
export default Presence;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<!-- An open thread: its comments, a reply field, and resolve and delete actions. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import { useComments } from './context.js';
|
|
5
|
+
import type { CommentThread } from '../shared/types.js';
|
|
6
|
+
import Avatar from './Avatar.svelte';
|
|
7
|
+
import Composer from './Composer.svelte';
|
|
8
|
+
import Icon from './Icon.svelte';
|
|
9
|
+
import Menu from './Menu.svelte';
|
|
10
|
+
import MenuItem from './MenuItem.svelte';
|
|
11
|
+
import { ago, fullDate } from '../core/time.js';
|
|
12
|
+
import { activity } from '../core/presence.js';
|
|
13
|
+
import { authorLabel } from './text.js';
|
|
14
|
+
|
|
15
|
+
let { thread, title, link, maxHeight, onclose }: {
|
|
16
|
+
thread: CommentThread;
|
|
17
|
+
/** Where it is: its surface's or page's title. */
|
|
18
|
+
title: string;
|
|
19
|
+
/** Absolute URL that opens this thread. */
|
|
20
|
+
link?: string;
|
|
21
|
+
maxHeight: number;
|
|
22
|
+
onclose: () => void;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
const vc = useComments();
|
|
26
|
+
const comments = vc.store;
|
|
27
|
+
const viewer = $derived(vc.viewer);
|
|
28
|
+
const details = $derived(Object.entries(thread.context).map(([key, value]) => `${key}: ${value}`).join('\n'));
|
|
29
|
+
const isAuthor = $derived(thread.comments[0]?.author.sub === viewer?.sub);
|
|
30
|
+
let editing = $state<number>();
|
|
31
|
+
/** What others are doing in this thread, the working before the typing; the same line once. */
|
|
32
|
+
const doing = $derived([...new Set(vc.participants
|
|
33
|
+
.filter((p) => p.thread === thread.id && p.state !== 'viewing' && p.author.sub !== viewer?.sub)
|
|
34
|
+
.sort((a, b) => Number(b.state === 'working') - Number(a.state === 'working'))
|
|
35
|
+
.map(activity)
|
|
36
|
+
.filter((line): line is string => Boolean(line)))]);
|
|
37
|
+
let list = $state<HTMLOListElement>();
|
|
38
|
+
|
|
39
|
+
// Follow the conversation to its newest comment.
|
|
40
|
+
$effect(() => {
|
|
41
|
+
void thread.comments.length;
|
|
42
|
+
void tick().then(() => list?.scrollTo({ top: list.scrollHeight }));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function remove(commentId: number, first: boolean) {
|
|
46
|
+
if (first && !confirm('Delete this thread and all its replies?')) return;
|
|
47
|
+
await comments.removeComment(commentId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function copyLink() {
|
|
51
|
+
if (link) void navigator.clipboard?.writeText(link);
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<div
|
|
56
|
+
data-vc-ui
|
|
57
|
+
role="dialog"
|
|
58
|
+
aria-label="Comment #{thread.id}"
|
|
59
|
+
class="pointer-events-auto flex w-[320px] cursor-default flex-col overflow-hidden rounded-[12px] bg-white text-[13px] text-vc-ink shadow-[0_4px_24px_rgb(0_0_0/0.22)]"
|
|
60
|
+
style:max-height="{maxHeight}px"
|
|
61
|
+
>
|
|
62
|
+
<header class="flex h-11 shrink-0 items-center gap-0.5 border-b border-vc-line/70 pr-1.5 pl-3.5">
|
|
63
|
+
<p class="min-w-0 flex-1 truncate text-vc-muted" title={details || undefined}>#{thread.id} · {title}</p>
|
|
64
|
+
{#if viewer}
|
|
65
|
+
<Menu label="Thread options" align="end" class="vc-tool-light size-7 rounded-full">
|
|
66
|
+
{#snippet trigger()}<Icon name="more" />{/snippet}
|
|
67
|
+
{#snippet children(close)}
|
|
68
|
+
<MenuItem onselect={() => { close(); void comments.markRead(thread.id, false); onclose(); }}>Mark as unread</MenuItem>
|
|
69
|
+
{#if link}<MenuItem onselect={() => { close(); copyLink(); }}>Copy link</MenuItem>{/if}
|
|
70
|
+
{#if isAuthor}
|
|
71
|
+
<MenuItem onselect={() => { close(); void remove(thread.comments[0].id, true); }}>Delete thread…</MenuItem>
|
|
72
|
+
{/if}
|
|
73
|
+
{/snippet}
|
|
74
|
+
</Menu>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class={['vc-tool-light size-7 rounded-full', thread.resolvedAt && 'text-vc-comment']}
|
|
78
|
+
aria-pressed={Boolean(thread.resolvedAt)}
|
|
79
|
+
title={thread.resolvedAt ? 'Reopen' : 'Resolve'}
|
|
80
|
+
aria-label={thread.resolvedAt ? 'Reopen' : 'Resolve'}
|
|
81
|
+
onclick={() => {
|
|
82
|
+
const resolving = !thread.resolvedAt;
|
|
83
|
+
void comments.resolve(thread.id, resolving);
|
|
84
|
+
if (resolving) onclose();
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<Icon name="resolve" size={18} />
|
|
88
|
+
</button>
|
|
89
|
+
{/if}
|
|
90
|
+
<button type="button" class="vc-tool-light size-7 rounded-full" title="Close (Esc)" aria-label="Close" onclick={onclose}>
|
|
91
|
+
<Icon name="close" />
|
|
92
|
+
</button>
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
{#if thread.resolvedAt}
|
|
96
|
+
<p class="shrink-0 border-b border-vc-line/70 bg-vc-comment-soft px-3.5 py-1.5 text-xs text-vc-ink">
|
|
97
|
+
Resolved{thread.resolvedBy ? ` by ${thread.resolvedBy.name}` : ''}, {ago(thread.resolvedAt, comments.now)}
|
|
98
|
+
</p>
|
|
99
|
+
{/if}
|
|
100
|
+
|
|
101
|
+
<ol bind:this={list} data-vc-scroll class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 py-1">
|
|
102
|
+
{#each thread.comments as comment, index (comment.id)}
|
|
103
|
+
{@const own = comment.author.sub === viewer?.sub}
|
|
104
|
+
<li class={['group flex gap-2.5 py-2.5', comment.pending && 'opacity-60']}>
|
|
105
|
+
<Avatar user={comment.author} size={24} />
|
|
106
|
+
<div class="min-w-0 flex-1">
|
|
107
|
+
<div class="flex h-6 items-center gap-1.5">
|
|
108
|
+
<p class="min-w-0 truncate">
|
|
109
|
+
<span class="font-medium" title={comment.author.agent ? authorLabel(comment.author) : undefined}>{comment.author.name}</span>
|
|
110
|
+
<time class="ml-1 text-vc-muted" datetime={new Date(comment.createdAt).toISOString()} title={fullDate(comment.createdAt)}>
|
|
111
|
+
{comment.pending ? 'sending…' : ago(comment.createdAt, comments.now)}
|
|
112
|
+
</time>
|
|
113
|
+
{#if comment.editedAt}<span class="text-vc-muted" title={fullDate(comment.editedAt)}> · edited</span>{/if}
|
|
114
|
+
</p>
|
|
115
|
+
{#if own && !comment.pending && editing !== comment.id}
|
|
116
|
+
<div class="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100">
|
|
117
|
+
<Menu label="Comment options" align="end" class="vc-tool-light size-6 rounded-full">
|
|
118
|
+
{#snippet trigger()}<Icon name="more" size={14} />{/snippet}
|
|
119
|
+
{#snippet children(close)}
|
|
120
|
+
<MenuItem onselect={() => { close(); editing = comment.id; }}>Edit</MenuItem>
|
|
121
|
+
<MenuItem onselect={() => { close(); void remove(comment.id, index === 0); }}>
|
|
122
|
+
{index === 0 ? 'Delete thread…' : 'Delete'}
|
|
123
|
+
</MenuItem>
|
|
124
|
+
{/snippet}
|
|
125
|
+
</Menu>
|
|
126
|
+
</div>
|
|
127
|
+
{/if}
|
|
128
|
+
</div>
|
|
129
|
+
{#if editing === comment.id}
|
|
130
|
+
<div class="mt-1">
|
|
131
|
+
<Composer
|
|
132
|
+
variant="edit"
|
|
133
|
+
placeholder="Edit comment"
|
|
134
|
+
initial={comment.body}
|
|
135
|
+
initialImages={comment.images}
|
|
136
|
+
autofocus
|
|
137
|
+
oninput={() => vc.noteTyping()}
|
|
138
|
+
onsubmit={async (body, images) => {
|
|
139
|
+
vc.stopTyping();
|
|
140
|
+
await comments.edit(comment.id, body, images);
|
|
141
|
+
editing = undefined;
|
|
142
|
+
}}
|
|
143
|
+
oncancel={() => (editing = undefined)}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
{:else}
|
|
147
|
+
{#if comment.body}<p class="break-words whitespace-pre-wrap">{comment.body}</p>{/if}
|
|
148
|
+
{#if comment.images.length}
|
|
149
|
+
{@const several = comment.images.length > 1}
|
|
150
|
+
<div class={['mt-1.5 grid gap-1.5', several && 'grid-cols-2']}>
|
|
151
|
+
{#each comment.images as image, i (image.id)}
|
|
152
|
+
<a
|
|
153
|
+
href={comments.imageUrl(image)}
|
|
154
|
+
target="_blank"
|
|
155
|
+
rel="noopener"
|
|
156
|
+
title="Open full size"
|
|
157
|
+
class="block overflow-hidden rounded-lg border border-vc-line/70 bg-vc-surface hover:border-vc-line"
|
|
158
|
+
>
|
|
159
|
+
<img
|
|
160
|
+
src={comments.imageUrl(image)}
|
|
161
|
+
alt="Image {i + 1}"
|
|
162
|
+
width={image.width}
|
|
163
|
+
height={image.height}
|
|
164
|
+
loading="lazy"
|
|
165
|
+
class={['block w-full', several ? 'aspect-square object-cover' : 'h-auto max-h-64 object-contain']}
|
|
166
|
+
/>
|
|
167
|
+
</a>
|
|
168
|
+
{/each}
|
|
169
|
+
</div>
|
|
170
|
+
{/if}
|
|
171
|
+
{/if}
|
|
172
|
+
</div>
|
|
173
|
+
</li>
|
|
174
|
+
{/each}
|
|
175
|
+
</ol>
|
|
176
|
+
|
|
177
|
+
{#if doing.length}
|
|
178
|
+
<div class="shrink-0 space-y-0.5 px-3.5 pb-2 text-xs text-vc-muted" aria-live="polite">
|
|
179
|
+
{#each doing as line (line)}
|
|
180
|
+
<p class="truncate" title={line}>{line}</p>
|
|
181
|
+
{/each}
|
|
182
|
+
</div>
|
|
183
|
+
{/if}
|
|
184
|
+
|
|
185
|
+
{#if viewer}
|
|
186
|
+
<footer class="flex shrink-0 items-start gap-2.5 border-t border-vc-line/70 px-3.5 py-3">
|
|
187
|
+
<Avatar user={viewer} size={24} class="mt-[3px]" />
|
|
188
|
+
<div class="min-w-0 flex-1">
|
|
189
|
+
{#key thread.id}
|
|
190
|
+
<Composer
|
|
191
|
+
placeholder="Reply"
|
|
192
|
+
autofocus
|
|
193
|
+
oninput={() => vc.noteTyping()}
|
|
194
|
+
onsubmit={(body, images) => { vc.stopTyping(); return comments.reply(thread.id, body, images); }}
|
|
195
|
+
oncancel={onclose}
|
|
196
|
+
/>
|
|
197
|
+
{/key}
|
|
198
|
+
</div>
|
|
199
|
+
</footer>
|
|
200
|
+
{/if}
|
|
201
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CommentThread } from '../shared/types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
thread: CommentThread;
|
|
4
|
+
/** Where it is: its surface's or page's title. */
|
|
5
|
+
title: string;
|
|
6
|
+
/** Absolute URL that opens this thread. */
|
|
7
|
+
link?: string;
|
|
8
|
+
maxHeight: number;
|
|
9
|
+
onclose: () => void;
|
|
10
|
+
};
|
|
11
|
+
declare const ThreadCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type ThreadCard = ReturnType<typeof ThreadCard>;
|
|
13
|
+
export default ThreadCard;
|