|
16 | 16 | <script lang="ts"> |
17 | 17 | import { browser } from '$app/environment'; |
18 | 18 | import type { Editor } from '@tiptap/core'; |
19 | | - import type { EditorBinding } from '@trestleinc/replicate/client'; |
| 19 | + import type { EditorBinding, CounterBinding, SetBinding, RegisterBinding } from '@trestleinc/replicate/client'; |
20 | 20 | import { getIntervalsContext } from '$lib/contexts/intervals.svelte'; |
21 | 21 | import type { Interval } from '$collections/useIntervals'; |
22 | 22 | import StatusIcon from './StatusIcon.svelte'; |
23 | 23 | import PriorityIcon from './PriorityIcon.svelte'; |
24 | 24 | import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; |
25 | 25 | import * as Avatar from '$lib/components/ui/avatar'; |
26 | 26 | import * as Tooltip from '$lib/components/ui/tooltip'; |
27 | | - import { Globe, Lock } from '@lucide/svelte'; |
| 27 | + import { Globe, Lock, X } from '@lucide/svelte'; |
28 | 28 | import { getAuthClient } from '$lib/auth-client'; |
29 | 29 |
|
30 | 30 | interface Props { |
31 | 31 | intervalId: string; |
32 | 32 | interval: Interval; |
33 | | - onPropertyUpdate?: (updates: Partial<Pick<Interval, 'status' | 'priority'>>) => void; |
| 33 | + showProperties?: boolean; |
34 | 34 | } |
35 | 35 |
|
36 | | - let { intervalId, interval, onPropertyUpdate }: Props = $props(); |
| 36 | + let { intervalId, interval, showProperties = false }: Props = $props(); |
37 | 37 |
|
38 | 38 | // Auth state for ownership check |
39 | 39 | let sessionData = $state<{ user?: { id: string } } | null>(null); |
|
59 | 59 | let editorElement = $state<HTMLDivElement | null>(null); |
60 | 60 | let editor = $state<Editor | null>(null); |
61 | 61 | let binding = $state<EditorBinding | null>(null); |
| 62 | + let counter = $state<CounterBinding | null>(null); |
| 63 | + let viewCount = $state<number>(0); |
62 | 64 | let error = $state<string | null>(null); |
63 | 65 | let isLoading = $state(true); |
64 | 66 |
|
|
75 | 77 | let editingTitle = $state(''); |
76 | 78 | let titleInputRef = $state<HTMLInputElement | null>(null); |
77 | 79 |
|
| 80 | + // Tags state |
| 81 | + let newTagInput = $state(''); |
| 82 | + let tagsBinding = $state<SetBinding<string> | null>(null); |
| 83 | + let tags = $state<string[]>([]); |
| 84 | +
|
| 85 | + // Status and Priority bindings (CRDT registers) |
| 86 | + let statusBinding = $state<RegisterBinding<StatusValue> | null>(null); |
| 87 | + let priorityBinding = $state<RegisterBinding<PriorityValue> | null>(null); |
| 88 | + let status = $state<StatusValue>(interval.status as StatusValue); |
| 89 | + let priority = $state<PriorityValue>(interval.priority as PriorityValue); |
| 90 | +
|
78 | 91 | const title = $derived(isEditingTitle ? editingTitle : interval.title); |
79 | 92 |
|
80 | 93 | $effect(() => { |
|
83 | 96 | } |
84 | 97 | }); |
85 | 98 |
|
| 99 | +
|
| 100 | + function addTag(tag: string) { |
| 101 | + const normalized = tag.trim().toLowerCase(); |
| 102 | + if (!normalized || tags.includes(normalized)) return; |
| 103 | +
|
| 104 | + tagsBinding?.add(normalized); |
| 105 | + newTagInput = ''; |
| 106 | + } |
| 107 | +
|
| 108 | + function removeTag(tag: string) { |
| 109 | + tagsBinding?.remove(tag); |
| 110 | + } |
| 111 | +
|
| 112 | + function handleTagKeyDown(e: KeyboardEvent) { |
| 113 | + if (e.key === 'Enter') { |
| 114 | + e.preventDefault(); |
| 115 | + addTag(newTagInput); |
| 116 | + } |
| 117 | + } |
| 118 | +
|
86 | 119 | // Subscribe to awareness changes for remote user avatars |
87 | 120 | $effect(() => { |
88 | 121 | const awareness = binding?.provider?.awareness; |
|
165 | 198 |
|
166 | 199 | return () => { |
167 | 200 | binding?.destroy(); |
| 201 | + counter?.destroy(); |
168 | 202 | if (editor) { |
169 | 203 | editor.destroy(); |
170 | 204 | editor = null; |
171 | 205 | } |
172 | 206 | binding = null; |
| 207 | + counter = null; |
173 | 208 | }; |
174 | 209 | }); |
175 | 210 |
|
|
197 | 232 | initBinding(); |
198 | 233 | }); |
199 | 234 |
|
| 235 | + // Initialize counter binding for page view tracking |
| 236 | + $effect(() => { |
| 237 | + if (!browser) return; |
| 238 | +
|
| 239 | + const initCounter = async () => { |
| 240 | + try { |
| 241 | + counter = await intervalsCtx.collection.utils.counter(intervalId, 'viewCount'); |
| 242 | + // Auto-increment view count on page load |
| 243 | + counter.increment(1); |
| 244 | + // Subscribe to view count changes |
| 245 | + counter.subscribe((value) => { |
| 246 | + viewCount = value; |
| 247 | + }); |
| 248 | + } catch (err) { |
| 249 | + // Counter errors are non-fatal, just log them |
| 250 | + console.error('Failed to initialize view counter:', err); |
| 251 | + } |
| 252 | + }; |
| 253 | +
|
| 254 | + initCounter(); |
| 255 | +
|
| 256 | + return () => { |
| 257 | + counter?.destroy(); |
| 258 | + }; |
| 259 | + }); |
| 260 | +
|
| 261 | + // Initialize tags binding for CRDT set |
| 262 | + $effect(() => { |
| 263 | + if (!browser) return; |
| 264 | +
|
| 265 | + const initTags = async () => { |
| 266 | + try { |
| 267 | + tagsBinding = await intervalsCtx.collection.utils.set<string>(intervalId, 'tags', { |
| 268 | + serialize: (item) => item, |
| 269 | + deserialize: (key) => key, |
| 270 | + }); |
| 271 | + // Subscribe to tag changes |
| 272 | + tagsBinding.subscribe((values) => { |
| 273 | + tags = values; |
| 274 | + }); |
| 275 | + } catch (err) { |
| 276 | + console.error('Failed to initialize tags binding:', err); |
| 277 | + } |
| 278 | + }; |
| 279 | +
|
| 280 | + initTags(); |
| 281 | +
|
| 282 | + return () => { |
| 283 | + tagsBinding?.destroy(); |
| 284 | + tagsBinding = null; |
| 285 | + }; |
| 286 | + }); |
| 287 | +
|
| 288 | + // Initialize status binding for CRDT register |
| 289 | + $effect(() => { |
| 290 | + if (!browser) return; |
| 291 | +
|
| 292 | + const initStatus = async () => { |
| 293 | + try { |
| 294 | + statusBinding = await intervalsCtx.collection.utils.register<StatusValue>( |
| 295 | + intervalId, |
| 296 | + 'status' |
| 297 | + ); |
| 298 | + // Subscribe to status changes |
| 299 | + statusBinding.subscribe((value) => { |
| 300 | + status = value; |
| 301 | + }); |
| 302 | + } catch (err) { |
| 303 | + console.error('Failed to initialize status binding:', err); |
| 304 | + } |
| 305 | + }; |
| 306 | +
|
| 307 | + initStatus(); |
| 308 | +
|
| 309 | + return () => { |
| 310 | + statusBinding?.destroy(); |
| 311 | + statusBinding = null; |
| 312 | + }; |
| 313 | + }); |
| 314 | +
|
| 315 | + // Initialize priority binding for CRDT register |
| 316 | + $effect(() => { |
| 317 | + if (!browser) return; |
| 318 | +
|
| 319 | + const initPriority = async () => { |
| 320 | + try { |
| 321 | + priorityBinding = await intervalsCtx.collection.utils.register<PriorityValue>( |
| 322 | + intervalId, |
| 323 | + 'priority' |
| 324 | + ); |
| 325 | + // Subscribe to priority changes |
| 326 | + priorityBinding.subscribe((value) => { |
| 327 | + priority = value; |
| 328 | + }); |
| 329 | + } catch (err) { |
| 330 | + console.error('Failed to initialize priority binding:', err); |
| 331 | + } |
| 332 | + }; |
| 333 | +
|
| 334 | + initPriority(); |
| 335 | +
|
| 336 | + return () => { |
| 337 | + priorityBinding?.destroy(); |
| 338 | + priorityBinding = null; |
| 339 | + }; |
| 340 | + }); |
| 341 | +
|
200 | 342 | function startEditing() { |
201 | 343 | editingTitle = interval.title; |
202 | 344 | isEditingTitle = true; |
|
255 | 397 | </button> |
256 | 398 | {/if} |
257 | 399 |
|
258 | | - {#if onPropertyUpdate} |
| 400 | + {#if showProperties} |
259 | 401 | <div |
260 | 402 | class="border-border mt-4 mb-6 flex items-center justify-between gap-4 border-b pb-4 text-sm" |
261 | 403 | > |
|
264 | 406 | <DropdownMenu.Trigger |
265 | 407 | class="hover:bg-muted transition-fast flex items-center gap-2 px-2 py-1" |
266 | 408 | > |
267 | | - <StatusIcon status={interval.status as StatusValue} size={14} /> |
268 | | - <span class="text-sm">{StatusLabels[interval.status as StatusValue]}</span> |
| 409 | + <StatusIcon {status} size={14} /> |
| 410 | + <span class="text-sm">{StatusLabels[status]}</span> |
269 | 411 | </DropdownMenu.Trigger> |
270 | 412 | <DropdownMenu.Content align="start"> |
271 | 413 | <DropdownMenu.RadioGroup |
272 | | - value={interval.status} |
273 | | - onValueChange={(v) => onPropertyUpdate({ status: v as StatusValue })} |
| 414 | + value={status} |
| 415 | + onValueChange={(v) => statusBinding?.set(v as StatusValue)} |
274 | 416 | > |
275 | | - {#each statusOptions as status (status)} |
276 | | - <DropdownMenu.RadioItem value={status}> |
277 | | - <StatusIcon {status} size={14} /> |
278 | | - <span class="ml-2">{StatusLabels[status]}</span> |
| 417 | + {#each statusOptions as statusOption (statusOption)} |
| 418 | + <DropdownMenu.RadioItem value={statusOption}> |
| 419 | + <StatusIcon status={statusOption} size={14} /> |
| 420 | + <span class="ml-2">{StatusLabels[statusOption]}</span> |
279 | 421 | </DropdownMenu.RadioItem> |
280 | 422 | {/each} |
281 | 423 | </DropdownMenu.RadioGroup> |
|
286 | 428 | <DropdownMenu.Trigger |
287 | 429 | class="hover:bg-muted transition-fast flex items-center gap-2 px-2 py-1" |
288 | 430 | > |
289 | | - <PriorityIcon priority={interval.priority as PriorityValue} size={14} /> |
290 | | - <span class="text-sm">{PriorityLabels[interval.priority as PriorityValue]}</span> |
| 431 | + <PriorityIcon {priority} size={14} /> |
| 432 | + <span class="text-sm">{PriorityLabels[priority]}</span> |
291 | 433 | </DropdownMenu.Trigger> |
292 | 434 | <DropdownMenu.Content align="start"> |
293 | 435 | <DropdownMenu.RadioGroup |
294 | | - value={interval.priority} |
295 | | - onValueChange={(v) => onPropertyUpdate({ priority: v as PriorityValue })} |
| 436 | + value={priority} |
| 437 | + onValueChange={(v) => priorityBinding?.set(v as PriorityValue)} |
296 | 438 | > |
297 | | - {#each priorityOptions as priority (priority)} |
298 | | - <DropdownMenu.RadioItem value={priority}> |
299 | | - <PriorityIcon {priority} size={14} /> |
300 | | - <span class="ml-2">{PriorityLabels[priority]}</span> |
| 439 | + {#each priorityOptions as priorityOption (priorityOption)} |
| 440 | + <DropdownMenu.RadioItem value={priorityOption}> |
| 441 | + <PriorityIcon priority={priorityOption} size={14} /> |
| 442 | + <span class="ml-2">{PriorityLabels[priorityOption]}</span> |
301 | 443 | </DropdownMenu.RadioItem> |
302 | 444 | {/each} |
303 | 445 | </DropdownMenu.RadioGroup> |
|
325 | 467 | {/if} |
326 | 468 | </button> |
327 | 469 | {/if} |
| 470 | + |
| 471 | + <!-- Tags Section --> |
| 472 | + <div class="bg-border mx-2 h-4 w-px"></div> |
| 473 | + |
| 474 | + <div class="flex items-center gap-1.5"> |
| 475 | + {#each tags as tag (tag)} |
| 476 | + <span |
| 477 | + class="bg-secondary text-secondary-foreground flex items-center gap-1 px-2 py-0.5 text-xs font-medium" |
| 478 | + > |
| 479 | + {tag} |
| 480 | + <button |
| 481 | + type="button" |
| 482 | + class="hover:text-destructive -mr-0.5 p-0.5 transition-fast" |
| 483 | + onclick={() => removeTag(tag)} |
| 484 | + aria-label="Remove {tag} tag" |
| 485 | + > |
| 486 | + <X class="h-3 w-3" /> |
| 487 | + </button> |
| 488 | + </span> |
| 489 | + {/each} |
| 490 | + |
| 491 | + <input |
| 492 | + type="text" |
| 493 | + bind:value={newTagInput} |
| 494 | + onkeydown={handleTagKeyDown} |
| 495 | + placeholder="+ tag" |
| 496 | + class="bg-transparent text-muted-foreground placeholder:text-muted-foreground/50 h-6 w-16 px-1 text-xs outline-none transition-fast focus:w-24" |
| 497 | + /> |
| 498 | + </div> |
328 | 499 | </div> |
329 | 500 |
|
| 501 | + {#if viewCount > 0} |
| 502 | + <div class="text-muted-foreground text-sm" aria-label="View count"> |
| 503 | + {viewCount} view{viewCount === 1 ? '' : 's'} |
| 504 | + </div> |
| 505 | + {/if} |
| 506 | + |
330 | 507 | {#if remoteUsers.length > 0} |
331 | 508 | <Tooltip.Provider> |
332 | 509 | <div class="flex gap-1.5" aria-label="Active collaborators"> |
|
0 commit comments