promptslide 0.3.6 → 0.3.8

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.
@@ -2,10 +2,10 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
2
2
  import { dirname, join, basename, relative, extname } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
 
5
- import { bold, green, cyan, red, dim } from "../utils/ansi.mjs"
5
+ import { bold, green, cyan, red, yellow, dim } from "../utils/ansi.mjs"
6
6
  import { requireAuth } from "../utils/auth.mjs"
7
7
  import { captureSlideAsDataUri, isPlaywrightAvailable, createCaptureSession } from "../utils/export.mjs"
8
- import { publishToRegistry, registryItemExists, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, writeLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent, readDeckMeta, updateDeckMeta, readItemMeta, updateItemMeta } from "../utils/registry.mjs"
8
+ import { publishToRegistry, registryItemExists, fetchRegistryItem, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, writeLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent, readDeckMeta, updateDeckMeta, readItemMeta, updateItemMeta } from "../utils/registry.mjs"
9
9
  import { prompt, confirm, select, closePrompts } from "../utils/prompts.mjs"
10
10
  import { parseDeckConfig } from "../utils/deck-config.mjs"
11
11
 
@@ -15,12 +15,17 @@ const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "..", "package
15
15
  function readDeckSlug(cwd) {
16
16
  const lock = readLockfile(cwd)
17
17
  // Prefer stored deck slug — migrate old two-part format (e.g. "my-deck/name" → "my-deck")
18
- if (lock.deckSlug) return lock.deckSlug.split("/")[0]
18
+ // Sanitize to match validation rules (lowercase alphanumeric + hyphens only)
19
+ if (lock.deckSlug) return sanitizeSlug(lock.deckSlug.split("/")[0])
19
20
  // Migrate legacy deckPrefix (old lockfiles stored prefix separately)
20
- if (lock.deckPrefix) return lock.deckPrefix
21
+ if (lock.deckPrefix) return sanitizeSlug(lock.deckPrefix)
21
22
  return ""
22
23
  }
23
24
 
25
+ function sanitizeSlug(raw) {
26
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
27
+ }
28
+
24
29
  function defaultDeckSlug(cwd) {
25
30
  const dirName = basename(cwd)
26
31
  return dirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
@@ -91,6 +96,24 @@ function detectRegistryDeps(content) {
91
96
  return deps
92
97
  }
93
98
 
99
+ /** Detect sibling layout imports — both relative ("./foo") and absolute ("@/layouts/foo"). */
100
+ function detectLayoutSiblingDeps(content) {
101
+ const deps = []
102
+ // Relative imports: import ... from "./shared-footer"
103
+ const relativeRegex = /import\s+.*?\s+from\s+["']\.\/([^"']+)["']/g
104
+ for (const match of content.matchAll(relativeRegex)) {
105
+ const name = match[1].replace(/\.tsx?$/, "")
106
+ deps.push(name)
107
+ }
108
+ // Absolute imports: import ... from "@/layouts/shared-footer"
109
+ const absoluteRegex = /import\s+.*?\s+from\s+["']@\/layouts\/([^"']+)["']/g
110
+ for (const match of content.matchAll(absoluteRegex)) {
111
+ const name = match[1].replace(/\.tsx?$/, "")
112
+ if (!deps.includes(name)) deps.push(name)
113
+ }
114
+ return deps
115
+ }
116
+
94
117
  /**
95
118
  * Discover shared source files under src/ that aren't slides, layouts, or theme.
96
119
  * These are files in directories like src/components/, src/lib/, src/hooks/, etc.
@@ -451,6 +474,27 @@ export async function publish(args) {
451
474
  // Persist slug early so it's available even if publish fails partway
452
475
  updateLockfilePublishConfig(cwd, { deckSlug })
453
476
 
477
+ // Warn if this deck already exists in the registry
478
+ try {
479
+ const existing = await fetchRegistryItem(deckSlug, auth)
480
+ if (existing) {
481
+ const existingTitle = existing.title || existing.name || deckSlug
482
+ const existingVer = existing.version ? ` ${dim(`(v${existing.version})`)}` : ""
483
+ console.log(` ${yellow("Warning:")} ${cyan(deckSlug)} already exists in the registry.`)
484
+ console.log(` ${dim("Existing deck:")} ${existingTitle}${existingVer}`)
485
+ console.log()
486
+ const shouldContinue = await confirm("Do you want to overwrite it?")
487
+ if (!shouldContinue) {
488
+ console.log(` ${dim("Publish cancelled.")}`)
489
+ closePrompts()
490
+ return
491
+ }
492
+ console.log()
493
+ }
494
+ } catch {
495
+ // Existence check failure is non-blocking (item doesn't exist or network error)
496
+ }
497
+
454
498
  // Walk public/ to collect assets and build reference set
455
499
  const publicDir = join(cwd, "public")
456
500
  const publicAssets = []
@@ -687,8 +731,9 @@ export async function publish(args) {
687
731
 
688
732
  const assetDeps = detectAssetDepsInContent(content, deckSlug, publicFileSet)
689
733
  const npmDeps = detectNpmDeps(content)
734
+ const siblingDeps = detectLayoutSiblingDeps(content).map(d => `${deckSlug}/${d}`)
690
735
  const regDeps = hasTheme ? [`${deckSlug}/theme`] : []
691
- regDeps.push(...assetDeps)
736
+ regDeps.push(...siblingDeps, ...assetDeps)
692
737
 
693
738
  try {
694
739
  const result = await publishToRegistry({
@@ -772,9 +817,11 @@ export async function publish(args) {
772
817
 
773
818
  // ── Phase 5: Deck manifest (includes shared source modules) ──
774
819
  itemIndex++
820
+ const themeSlugs = hasTheme ? [`${deckSlug}/theme`] : []
821
+ const layoutSlugs = layoutEntries.map(f => `${deckSlug}/${f.replace(/\.tsx?$/, "")}`)
775
822
  const slideSlugs = slideEntries.map(f => `${deckSlug}/${f.replace(/\.tsx?$/, "")}`)
776
823
  const assetSlugs = publicAssets.map(a => assetFileToSlug(deckSlug, a.relativePath))
777
- const allDeckDeps = [...slideSlugs, ...assetSlugs]
824
+ const allDeckDeps = [...themeSlugs, ...layoutSlugs, ...slideSlugs, ...assetSlugs]
778
825
 
779
826
  // Bundle shared source files with the deck item so preview/install can resolve @/ imports
780
827
  const sharedFiles = sharedSources.map(s => ({
@@ -883,6 +930,27 @@ export async function publish(args) {
883
930
  console.log(` Slug: ${cyan(slug)}`)
884
931
  console.log()
885
932
 
933
+ // Warn if this item already exists in the registry
934
+ try {
935
+ const existing = await fetchRegistryItem(slug, auth)
936
+ if (existing) {
937
+ const existingTitle = existing.title || existing.name || slug
938
+ const existingVer = existing.version ? ` ${dim(`(v${existing.version})`)}` : ""
939
+ console.log(` ${yellow("Warning:")} ${cyan(slug)} already exists in the registry.`)
940
+ console.log(` ${dim("Existing item:")} ${existingTitle}${existingVer}`)
941
+ console.log()
942
+ const shouldContinue = await confirm("Do you want to overwrite it?")
943
+ if (!shouldContinue) {
944
+ console.log(` ${dim("Publish cancelled.")}`)
945
+ closePrompts()
946
+ return
947
+ }
948
+ console.log()
949
+ }
950
+ } catch {
951
+ // Existence check failure is non-blocking (item doesn't exist or network error)
952
+ }
953
+
886
954
  // Warn if same base slug exists under a different prefix
887
955
  try {
888
956
  const { items: results } = await searchRegistry({ search: baseSlug, type }, auth)
@@ -216,6 +216,35 @@ export async function pull(args) {
216
216
  }
217
217
  }
218
218
 
219
+ // Fetch annotations from registry
220
+ if (deckItem.id) {
221
+ try {
222
+ const annotationsRes = await fetch(`${auth.registry}/api/items/${deckItem.id}/annotations`, {
223
+ headers: { Authorization: `Bearer ${auth.token}`, ...(auth.organizationId ? { "X-Organization-Id": auth.organizationId } : {}) }
224
+ })
225
+ if (annotationsRes.ok) {
226
+ const data = await annotationsRes.json()
227
+ const annotations = data.annotations ?? []
228
+ if (annotations.length > 0) {
229
+ const annotationsFile = { version: 1, annotations: annotations.map(a => ({
230
+ id: a.id,
231
+ slideIndex: a.slideIndex,
232
+ slideTitle: a.slideTitle,
233
+ target: a.target,
234
+ body: a.body,
235
+ createdAt: a.createdAt,
236
+ status: a.status,
237
+ ...(a.resolution ? { resolution: a.resolution } : {})
238
+ })) }
239
+ writeFileSync(join(cwd, "annotations.json"), JSON.stringify(annotationsFile, null, 2) + "\n", "utf-8")
240
+ console.log(` ${green("+")} ${cyan("annotations.json")} ${dim(`(${annotations.length} annotation${annotations.length === 1 ? "" : 's'})`)}`)
241
+ }
242
+ }
243
+ } catch {
244
+ // Annotations are non-critical; don't fail the pull
245
+ }
246
+ }
247
+
219
248
  // Summary
220
249
  console.log()
221
250
  if (written.length === 0) {
@@ -0,0 +1,43 @@
1
+ import type { Annotation, AnnotationsFile, AnnotationStorageAdapter } from "../types"
2
+
3
+ const ENDPOINT = "/__promptslide_annotations"
4
+
5
+ async function loadAll(): Promise<Annotation[]> {
6
+ try {
7
+ const res = await fetch(ENDPOINT)
8
+ if (!res.ok) return []
9
+ const data: AnnotationsFile = await res.json()
10
+ return data.annotations || []
11
+ } catch {
12
+ return []
13
+ }
14
+ }
15
+
16
+ async function saveAll(annotations: Annotation[]): Promise<void> {
17
+ const data: AnnotationsFile = { version: 1, annotations }
18
+ await fetch(ENDPOINT, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify(data)
22
+ })
23
+ }
24
+
25
+ /** Storage adapter for local Vite dev server (reads/writes annotations.json via middleware) */
26
+ export function createHttpAdapter(): AnnotationStorageAdapter {
27
+ let cache: Annotation[] = []
28
+
29
+ return {
30
+ async load() {
31
+ cache = await loadAll()
32
+ return cache
33
+ },
34
+ async add(annotation) {
35
+ cache = [...cache, annotation]
36
+ await saveAll(cache)
37
+ },
38
+ async remove(id) {
39
+ cache = cache.filter(a => a.id !== id)
40
+ await saveAll(cache)
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import { ArrowUp, X } from "lucide-react"
3
+
4
+ interface AnnotationFormProps {
5
+ xPercent: number
6
+ yPercent: number
7
+ onSubmit: (text: string) => void
8
+ onCancel: () => void
9
+ }
10
+
11
+ export function AnnotationForm({ xPercent, yPercent, onSubmit, onCancel }: AnnotationFormProps) {
12
+ const [text, setText] = useState("")
13
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
14
+
15
+ useEffect(() => {
16
+ textareaRef.current?.focus()
17
+ }, [])
18
+
19
+ const handleSubmit = () => {
20
+ const trimmed = text.trim()
21
+ if (!trimmed) return
22
+ onSubmit(trimmed)
23
+ }
24
+
25
+ const handleKeyDown = (e: React.KeyboardEvent) => {
26
+ if (e.key === "Enter" && !e.shiftKey) {
27
+ e.preventDefault()
28
+ handleSubmit()
29
+ }
30
+ if (e.key === "Escape") {
31
+ onCancel()
32
+ }
33
+ e.stopPropagation()
34
+ }
35
+
36
+ // Position the form, flipping if near edges
37
+ const left = xPercent > 70 ? undefined : `${xPercent}%`
38
+ const right = xPercent > 70 ? `${100 - xPercent}%` : undefined
39
+ const top = yPercent > 70 ? undefined : `${yPercent}%`
40
+ const bottom = yPercent > 70 ? `${100 - yPercent}%` : undefined
41
+
42
+ return (
43
+ <div
44
+ role="dialog"
45
+ className="absolute z-40 w-72 overflow-hidden rounded-xl border border-white/[0.08] bg-neutral-900/95 shadow-2xl backdrop-blur-2xl"
46
+ style={{ left, right, top, bottom }}
47
+ onClick={e => e.stopPropagation()}
48
+ onKeyDown={e => e.stopPropagation()}
49
+ >
50
+ <div className="flex items-center justify-between border-b border-white/[0.06] px-3.5 py-2.5">
51
+ <span className="text-xs font-medium tracking-wide text-neutral-400">Add annotation</span>
52
+ <button
53
+ onClick={onCancel}
54
+ className="rounded-lg p-1 text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-neutral-300"
55
+ >
56
+ <X className="h-3.5 w-3.5" />
57
+ </button>
58
+ </div>
59
+ <div className="p-3">
60
+ <textarea
61
+ ref={textareaRef}
62
+ value={text}
63
+ onChange={e => setText(e.target.value)}
64
+ onKeyDown={handleKeyDown}
65
+ placeholder="Describe the change you want..."
66
+ className="w-full resize-none rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-[#FF6B35]/50 focus:bg-white/[0.06]"
67
+ rows={3}
68
+ />
69
+ <div className="mt-2.5 flex items-center justify-between">
70
+ <span className="text-[11px] text-neutral-600">Enter to send</span>
71
+ <button
72
+ onClick={handleSubmit}
73
+ disabled={!text.trim()}
74
+ className="flex h-7 w-7 items-center justify-center rounded-lg bg-[#FF6B35] text-white shadow-lg shadow-[#FF6B35]/20 transition-all hover:bg-[#FF7A4A] hover:shadow-[#FF6B35]/30 disabled:opacity-30 disabled:shadow-none disabled:hover:bg-[#FF6B35]"
75
+ >
76
+ <ArrowUp className="h-3.5 w-3.5" strokeWidth={2.5} />
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ )
82
+ }
@@ -0,0 +1,198 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
+ import type { SlideConfig } from "../types"
3
+ import { AnnotationForm } from "./annotation-form"
4
+ import { AnnotationPin } from "./annotation-pin"
5
+ import { buildElementTarget, resolveTarget } from "./selectors"
6
+ import type { Annotation, AnnotationTarget } from "./types"
7
+
8
+ interface AnnotationOverlayProps {
9
+ slides: SlideConfig[]
10
+ currentSlide: number
11
+ /** Ref to the slide container element (the div wrapping SlideRenderer) */
12
+ slideContainerRef: React.RefObject<HTMLDivElement | null>
13
+ selectedId: string | null
14
+ onSelectId: (id: string | null) => void
15
+ onShowPanel: () => void
16
+ slideAnnotations: Annotation[]
17
+ addAnnotation: (slideIndex: number, slideTitle: string, target: AnnotationTarget, body: string) => void
18
+ }
19
+
20
+ interface PendingAnnotation {
21
+ target: AnnotationTarget
22
+ xPercent: number
23
+ yPercent: number
24
+ }
25
+
26
+ export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, selectedId, onSelectId, onShowPanel, slideAnnotations, addAnnotation }: AnnotationOverlayProps) {
27
+ const [pending, setPending] = useState<PendingAnnotation | null>(null)
28
+ const [hoveredElement, setHoveredElement] = useState<DOMRect | null>(null)
29
+ const overlayRef = useRef<HTMLDivElement>(null)
30
+
31
+ const slideTitle = slides[currentSlide]?.title || `Slide ${currentSlide + 1}`
32
+
33
+ // Resolve annotation positions — use the element if found, otherwise fallback coordinates
34
+ const resolvedAnnotations = slideAnnotations.map((a, i) => {
35
+ const container = slideContainerRef.current
36
+ if (!container) {
37
+ return { annotation: a, xPercent: a.target.position.xPercent, yPercent: a.target.position.yPercent, number: i + 1 }
38
+ }
39
+
40
+ const { element } = resolveTarget(a.target, container)
41
+ if (element) {
42
+ const rect = element.getBoundingClientRect()
43
+ const containerRect = container.getBoundingClientRect()
44
+ const xPercent = ((rect.left + rect.width / 2 - containerRect.left) / containerRect.width) * 100
45
+ const yPercent = ((rect.top + rect.height / 2 - containerRect.top) / containerRect.height) * 100
46
+ return { annotation: a, xPercent, yPercent, number: i + 1 }
47
+ }
48
+
49
+ return { annotation: a, xPercent: a.target.position.xPercent, yPercent: a.target.position.yPercent, number: i + 1 }
50
+ })
51
+
52
+ const handleOverlayClick = useCallback(
53
+ (e: React.MouseEvent) => {
54
+ const container = slideContainerRef.current
55
+ if (!container) return
56
+
57
+ // Get the element under the click by temporarily hiding the overlay
58
+ const overlay = overlayRef.current
59
+ if (overlay) overlay.style.pointerEvents = "none"
60
+ const elementUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
61
+ if (overlay) overlay.style.pointerEvents = ""
62
+
63
+ if (!elementUnder || !container.contains(elementUnder)) {
64
+ setPending(null)
65
+ onSelectId(null)
66
+ return
67
+ }
68
+
69
+ // Pick the most specific meaningful element (skip tiny text nodes, pick their parent)
70
+ let target = elementUnder
71
+ if (target.tagName === "SPAN" && target.parentElement && container.contains(target.parentElement)) {
72
+ // For inline spans, prefer their parent for a more meaningful target
73
+ const parent = target.parentElement
74
+ if (parent.tagName !== "DIV" || parent.children.length <= 3) {
75
+ target = parent
76
+ }
77
+ }
78
+
79
+ const annotationTarget = buildElementTarget(target, container)
80
+ const containerRect = container.getBoundingClientRect()
81
+ const xPercent = ((e.clientX - containerRect.left) / containerRect.width) * 100
82
+ const yPercent = ((e.clientY - containerRect.top) / containerRect.height) * 100
83
+
84
+ setPending({ target: annotationTarget, xPercent, yPercent })
85
+ onSelectId(null)
86
+ },
87
+ [slideContainerRef, onSelectId]
88
+ )
89
+
90
+ const handleSubmit = useCallback(
91
+ (text: string) => {
92
+ if (!pending) return
93
+ addAnnotation(currentSlide, slideTitle, pending.target, text)
94
+ setPending(null)
95
+ onShowPanel()
96
+ },
97
+ [pending, currentSlide, slideTitle, addAnnotation, onShowPanel]
98
+ )
99
+
100
+ // Track hover for element highlighting
101
+ const handleMouseMove = useCallback(
102
+ (e: React.MouseEvent) => {
103
+ if (pending) {
104
+ setHoveredElement(null)
105
+ return
106
+ }
107
+
108
+ const container = slideContainerRef.current
109
+ if (!container) return
110
+
111
+ const overlay = overlayRef.current
112
+ if (overlay) overlay.style.pointerEvents = "none"
113
+ const elementUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
114
+ if (overlay) overlay.style.pointerEvents = ""
115
+
116
+ if (elementUnder && container.contains(elementUnder) && elementUnder !== container) {
117
+ const containerRect = container.getBoundingClientRect()
118
+ const elRect = elementUnder.getBoundingClientRect()
119
+ setHoveredElement(
120
+ new DOMRect(
121
+ elRect.left - containerRect.left,
122
+ elRect.top - containerRect.top,
123
+ elRect.width,
124
+ elRect.height
125
+ )
126
+ )
127
+ } else {
128
+ setHoveredElement(null)
129
+ }
130
+ },
131
+ [slideContainerRef, pending]
132
+ )
133
+
134
+ // Clear pending when slide changes
135
+ useEffect(() => {
136
+ setPending(null)
137
+ onSelectId(null)
138
+ setHoveredElement(null)
139
+ }, [currentSlide, onSelectId])
140
+
141
+ return (
142
+ <>
143
+ {/* Hover highlight */}
144
+ {hoveredElement && (
145
+ <div
146
+ className="pointer-events-none absolute z-20 rounded-lg border-2 border-dashed border-[#FF6B35]/50 bg-[#FF6B35]/5"
147
+ style={{
148
+ left: hoveredElement.x,
149
+ top: hoveredElement.y,
150
+ width: hoveredElement.width,
151
+ height: hoveredElement.height
152
+ }}
153
+ />
154
+ )}
155
+
156
+ {/* Click capture overlay */}
157
+ <div
158
+ ref={overlayRef}
159
+ role="button"
160
+ tabIndex={0}
161
+ className="absolute inset-0 z-20"
162
+ style={{ cursor: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Ccircle cx='12' cy='12' r='8' stroke='%23FF6B35' stroke-width='2' opacity='0.8'/%3E%3Ccircle cx='12' cy='12' r='2' fill='%23FF6B35'/%3E%3C/svg%3E") 12 12, crosshair` }}
163
+ onClick={handleOverlayClick}
164
+ onKeyDown={e => { if (e.key === "Escape") { setPending(null); onSelectId(null) } }}
165
+ onMouseMove={handleMouseMove}
166
+ onMouseLeave={() => setHoveredElement(null)}
167
+ />
168
+
169
+ {/* Annotation pins */}
170
+ {resolvedAnnotations.map(({ annotation, xPercent, yPercent, number }) => (
171
+ <AnnotationPin
172
+ key={annotation.id}
173
+ number={number}
174
+ status={annotation.status}
175
+ xPercent={xPercent}
176
+ yPercent={yPercent}
177
+ isSelected={annotation.id === selectedId}
178
+ onClick={() => {
179
+ onSelectId(annotation.id === selectedId ? null : annotation.id)
180
+ onShowPanel()
181
+ setPending(null)
182
+ }}
183
+ />
184
+ ))}
185
+
186
+ {/* New annotation form */}
187
+ {pending && (
188
+ <AnnotationForm
189
+ xPercent={pending.xPercent}
190
+ yPercent={pending.yPercent}
191
+ onSubmit={handleSubmit}
192
+ onCancel={() => setPending(null)}
193
+ />
194
+ )}
195
+
196
+ </>
197
+ )
198
+ }
@@ -0,0 +1,150 @@
1
+ import { MessageCircle, Trash2, X } from "lucide-react"
2
+ import type { Annotation } from "./types"
3
+
4
+ interface AnnotationPanelProps {
5
+ annotations: Annotation[]
6
+ selectedId: string | null
7
+ onSelect: (id: string) => void
8
+ onDelete: (id: string) => void
9
+ onClose: () => void
10
+ }
11
+
12
+ export function AnnotationPanel({ annotations, selectedId, onSelect, onDelete, onClose }: AnnotationPanelProps) {
13
+ const open = annotations.filter(a => a.status === "open")
14
+ const resolved = annotations.filter(a => a.status === "resolved")
15
+
16
+ return (
17
+ <div className="flex h-full w-80 flex-shrink-0 flex-col border-l border-white/[0.06] bg-neutral-950/95 backdrop-blur-2xl">
18
+ <div className="flex items-center justify-between px-4 py-3.5">
19
+ <div className="flex items-center gap-2.5">
20
+ <div className="flex h-6 w-6 items-center justify-center rounded-lg bg-[#FF6B35]/15">
21
+ <MessageCircle className="h-3.5 w-3.5 text-[#FF6B35]" />
22
+ </div>
23
+ <span className="text-sm font-medium text-white">Annotations</span>
24
+ {open.length > 0 && (
25
+ <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-[#FF6B35]/15 px-1.5 text-[11px] font-semibold text-[#FF6B35]">
26
+ {open.length}
27
+ </span>
28
+ )}
29
+ </div>
30
+ <button
31
+ onClick={onClose}
32
+ className="rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-neutral-300"
33
+ >
34
+ <X className="h-4 w-4" />
35
+ </button>
36
+ </div>
37
+
38
+ <div className="h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" />
39
+
40
+ <div className="flex-1 overflow-y-auto p-2">
41
+ {annotations.length === 0 && (
42
+ <div className="flex flex-col items-center gap-2 px-4 py-8 text-center">
43
+ <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/[0.04]">
44
+ <MessageCircle className="h-5 w-5 text-neutral-600" />
45
+ </div>
46
+ <p className="text-sm text-neutral-500">Click on slide elements to add annotations</p>
47
+ </div>
48
+ )}
49
+
50
+ {open.length > 0 && (
51
+ <div>
52
+ <div className="mb-1.5 px-2 pt-1 text-[11px] font-medium tracking-wider text-neutral-500 uppercase">
53
+ Open
54
+ </div>
55
+ {open.map((a, i) => (
56
+ <AnnotationItem
57
+ key={a.id}
58
+ annotation={a}
59
+ number={i + 1}
60
+ isSelected={a.id === selectedId}
61
+ onSelect={() => onSelect(a.id)}
62
+ onDelete={() => onDelete(a.id)}
63
+ />
64
+ ))}
65
+ </div>
66
+ )}
67
+
68
+ {resolved.length > 0 && (
69
+ <div className={open.length > 0 ? "mt-3" : ""}>
70
+ <div className="mb-1.5 px-2 pt-1 text-[11px] font-medium tracking-wider text-neutral-500 uppercase">
71
+ Resolved
72
+ </div>
73
+ {resolved.map((a, i) => (
74
+ <AnnotationItem
75
+ key={a.id}
76
+ annotation={a}
77
+ number={open.length + i + 1}
78
+ isSelected={a.id === selectedId}
79
+ onSelect={() => onSelect(a.id)}
80
+ onDelete={() => onDelete(a.id)}
81
+ />
82
+ ))}
83
+ </div>
84
+ )}
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ function AnnotationItem({
91
+ annotation,
92
+ number,
93
+ isSelected,
94
+ onSelect,
95
+ onDelete
96
+ }: {
97
+ annotation: Annotation
98
+ number: number
99
+ isSelected: boolean
100
+ onSelect: () => void
101
+ onDelete: () => void
102
+ }) {
103
+ return (
104
+ <div
105
+ role="button"
106
+ tabIndex={0}
107
+ onClick={onSelect}
108
+ onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onSelect() }}
109
+ className={`group relative mb-0.5 cursor-pointer rounded-xl p-2.5 transition-all duration-150 ${
110
+ isSelected
111
+ ? "bg-[#FF6B35]/10 ring-1 ring-[#FF6B35]/20"
112
+ : "hover:bg-white/[0.04]"
113
+ }`}
114
+ >
115
+ <button
116
+ onClick={e => {
117
+ e.stopPropagation()
118
+ onDelete()
119
+ }}
120
+ className="absolute top-2 right-2 rounded-lg p-1 text-neutral-600 opacity-0 transition-all hover:bg-white/[0.08] hover:text-neutral-300 group-hover:opacity-100"
121
+ >
122
+ <Trash2 className="h-3 w-3" />
123
+ </button>
124
+ <div className="flex items-start gap-2.5">
125
+ <div
126
+ className={`mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-semibold ${
127
+ annotation.status === "open"
128
+ ? "bg-[#FF6B35] text-white"
129
+ : "bg-neutral-700 text-neutral-400"
130
+ }`}
131
+ >
132
+ {number}
133
+ </div>
134
+ <div className="min-w-0 pr-4">
135
+ <p className="text-[13px] leading-relaxed text-neutral-200">{annotation.body}</p>
136
+ {annotation.target.contentNearPin && (
137
+ <p className="mt-1 truncate text-[11px] text-neutral-600">
138
+ {annotation.target.contentNearPin}
139
+ </p>
140
+ )}
141
+ {annotation.resolution && (
142
+ <p className="mt-1.5 text-[11px] text-emerald-400/80 italic">
143
+ {annotation.resolution}
144
+ </p>
145
+ )}
146
+ </div>
147
+ </div>
148
+ </div>
149
+ )
150
+ }
@@ -0,0 +1,42 @@
1
+ interface AnnotationPinProps {
2
+ number: number
3
+ status: "open" | "resolved"
4
+ xPercent: number
5
+ yPercent: number
6
+ isSelected: boolean
7
+ onClick: () => void
8
+ }
9
+
10
+ export function AnnotationPin({ number, status, xPercent, yPercent, isSelected, onClick }: AnnotationPinProps) {
11
+ const isResolved = status === "resolved"
12
+
13
+ return (
14
+ <button
15
+ onClick={e => {
16
+ e.stopPropagation()
17
+ onClick()
18
+ }}
19
+ className="absolute z-30 -translate-x-1/2 -translate-y-1/2 transition-all duration-200 hover:scale-110"
20
+ style={{ left: `${xPercent}%`, top: `${yPercent}%` }}
21
+ title={`Annotation #${number}`}
22
+ >
23
+ {/* Glow ring */}
24
+ {isSelected && !isResolved && (
25
+ <div className="absolute inset-[-4px] animate-pulse rounded-full bg-[#FF6B35]/25 blur-sm" />
26
+ )}
27
+ <div
28
+ className={`relative flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold shadow-lg backdrop-blur-sm transition-all duration-200 ${
29
+ isSelected
30
+ ? isResolved
31
+ ? "bg-neutral-500/90 text-white ring-2 ring-neutral-400/50"
32
+ : "bg-[#FF6B35] text-white ring-2 ring-[#FF6B35]/40 ring-offset-1 ring-offset-black/50"
33
+ : isResolved
34
+ ? "bg-neutral-700/80 text-neutral-400"
35
+ : "bg-[#FF6B35]/90 text-white hover:bg-[#FF6B35]"
36
+ }`}
37
+ >
38
+ {number}
39
+ </div>
40
+ </button>
41
+ )
42
+ }