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.
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +80 -2
- package/dist/index.js +700 -121
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/create.mjs +41 -5
- package/src/commands/publish.mjs +49 -2
- package/src/commands/pull.mjs +29 -0
- package/src/core/annotations/adapters/http.ts +43 -0
- package/src/core/annotations/annotation-form.tsx +82 -0
- package/src/core/annotations/annotation-overlay.tsx +198 -0
- package/src/core/annotations/annotation-panel.tsx +150 -0
- package/src/core/annotations/annotation-pin.tsx +42 -0
- package/src/core/annotations/api.ts +23 -0
- package/src/core/annotations/index.ts +5 -0
- package/src/core/annotations/selectors.ts +76 -0
- package/src/core/annotations/types.ts +47 -0
- package/src/core/annotations/use-annotations.ts +50 -0
- package/src/core/index.ts +4 -0
- package/src/core/slide-deck.tsx +168 -83
- package/src/vite/plugin.mjs +54 -0
package/src/commands/publish.mjs
CHANGED
|
@@ -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)
|
package/src/commands/pull.mjs
CHANGED
|
@@ -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"
|