promptslide 0.3.5 → 0.3.7

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
 
@@ -393,6 +393,11 @@ export async function publish(args) {
393
393
 
394
394
  const auth = requireAuth()
395
395
 
396
+ if (auth.organizationName) {
397
+ console.log(` ${dim("Organization:")} ${bold(auth.organizationName)} ${dim(`· switch with promptslide org`)}`)
398
+ console.log()
399
+ }
400
+
396
401
  // Determine file to publish
397
402
  let typeOverride = null
398
403
  const typeIdx = args.indexOf("--type")
@@ -446,6 +451,27 @@ export async function publish(args) {
446
451
  // Persist slug early so it's available even if publish fails partway
447
452
  updateLockfilePublishConfig(cwd, { deckSlug })
448
453
 
454
+ // Warn if this deck already exists in the registry
455
+ try {
456
+ const existing = await fetchRegistryItem(deckSlug, auth)
457
+ if (existing) {
458
+ const existingTitle = existing.title || existing.name || deckSlug
459
+ const existingVer = existing.version ? ` ${dim(`(v${existing.version})`)}` : ""
460
+ console.log(` ${yellow("Warning:")} ${cyan(deckSlug)} already exists in the registry.`)
461
+ console.log(` ${dim("Existing deck:")} ${existingTitle}${existingVer}`)
462
+ console.log()
463
+ const shouldContinue = await confirm("Do you want to overwrite it?")
464
+ if (!shouldContinue) {
465
+ console.log(` ${dim("Publish cancelled.")}`)
466
+ closePrompts()
467
+ return
468
+ }
469
+ console.log()
470
+ }
471
+ } catch {
472
+ // Existence check failure is non-blocking (item doesn't exist or network error)
473
+ }
474
+
449
475
  // Walk public/ to collect assets and build reference set
450
476
  const publicDir = join(cwd, "public")
451
477
  const publicAssets = []
@@ -878,6 +904,27 @@ export async function publish(args) {
878
904
  console.log(` Slug: ${cyan(slug)}`)
879
905
  console.log()
880
906
 
907
+ // Warn if this item already exists in the registry
908
+ try {
909
+ const existing = await fetchRegistryItem(slug, auth)
910
+ if (existing) {
911
+ const existingTitle = existing.title || existing.name || slug
912
+ const existingVer = existing.version ? ` ${dim(`(v${existing.version})`)}` : ""
913
+ console.log(` ${yellow("Warning:")} ${cyan(slug)} already exists in the registry.`)
914
+ console.log(` ${dim("Existing item:")} ${existingTitle}${existingVer}`)
915
+ console.log()
916
+ const shouldContinue = await confirm("Do you want to overwrite it?")
917
+ if (!shouldContinue) {
918
+ console.log(` ${dim("Publish cancelled.")}`)
919
+ closePrompts()
920
+ return
921
+ }
922
+ console.log()
923
+ }
924
+ } catch {
925
+ // Existence check failure is non-blocking (item doesn't exist or network error)
926
+ }
927
+
881
928
  // Warn if same base slug exists under a different prefix
882
929
  try {
883
930
  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
+ }
@@ -0,0 +1,23 @@
1
+ import type { Annotation, AnnotationsFile } from "./types"
2
+
3
+ const ENDPOINT = "/__promptslide_annotations"
4
+
5
+ export async function fetchAnnotations(): 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
+ export async function saveAnnotations(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
+ }
@@ -0,0 +1,5 @@
1
+ export { AnnotationOverlay } from "./annotation-overlay"
2
+ export { AnnotationPanel } from "./annotation-panel"
3
+ export { useAnnotations } from "./use-annotations"
4
+ export { createHttpAdapter } from "./adapters/http"
5
+ export type { Annotation, AnnotationsFile, AnnotationStorageAdapter, AnnotationTarget } from "./types"