wiki-viewer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.next/standalone/.github/workflows/publish.yml +37 -0
  2. package/.next/standalone/DESIGN.md +521 -0
  3. package/.next/standalone/README.md +283 -0
  4. package/.next/standalone/bin/wiki-viewer.js +158 -0
  5. package/.next/standalone/next.config.ts +11 -0
  6. package/.next/standalone/package.json +120 -0
  7. package/.next/standalone/pnpm-lock.yaml +5654 -0
  8. package/.next/standalone/pnpm-workspace.yaml +2 -0
  9. package/.next/standalone/postcss.config.mjs +2 -0
  10. package/.next/standalone/public/logo.svg +11 -0
  11. package/.next/standalone/public/public/logo.svg +11 -0
  12. package/.next/standalone/server.js +44 -0
  13. package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
  14. package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
  15. package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
  16. package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
  17. package/.next/standalone/src/app/api/system/config/route.ts +10 -0
  18. package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
  19. package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
  20. package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
  21. package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
  22. package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
  23. package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
  24. package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
  25. package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
  26. package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
  27. package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
  28. package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
  29. package/.next/standalone/src/app/api/wiki/route.ts +98 -0
  30. package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
  31. package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
  32. package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
  33. package/.next/standalone/src/app/globals.css +716 -0
  34. package/.next/standalone/src/app/icon.svg +11 -0
  35. package/.next/standalone/src/app/layout.tsx +35 -0
  36. package/.next/standalone/src/app/page.tsx +1509 -0
  37. package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
  38. package/.next/standalone/src/components/dir-picker.tsx +393 -0
  39. package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
  40. package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
  41. package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
  42. package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
  43. package/.next/standalone/src/components/editor/editor.tsx +633 -0
  44. package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
  45. package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
  46. package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
  47. package/.next/standalone/src/components/editor/extensions.ts +148 -0
  48. package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
  49. package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
  50. package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
  51. package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
  52. package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
  53. package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
  54. package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
  55. package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
  56. package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
  57. package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
  58. package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
  59. package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
  60. package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
  61. package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
  62. package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
  63. package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
  64. package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
  65. package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
  66. package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
  67. package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
  68. package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
  69. package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
  70. package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
  71. package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
  72. package/.next/standalone/src/components/theme-provider.tsx +16 -0
  73. package/.next/standalone/src/components/theme-toggle.tsx +24 -0
  74. package/.next/standalone/src/components/ui/badge.tsx +35 -0
  75. package/.next/standalone/src/components/ui/button.tsx +56 -0
  76. package/.next/standalone/src/components/ui/card.tsx +63 -0
  77. package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
  78. package/.next/standalone/src/components/ui/command.tsx +150 -0
  79. package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
  80. package/.next/standalone/src/components/ui/dialog.tsx +119 -0
  81. package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
  82. package/.next/standalone/src/components/ui/input.tsx +22 -0
  83. package/.next/standalone/src/components/ui/label.tsx +25 -0
  84. package/.next/standalone/src/components/ui/popover.tsx +33 -0
  85. package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
  86. package/.next/standalone/src/components/ui/select.tsx +156 -0
  87. package/.next/standalone/src/components/ui/separator.tsx +30 -0
  88. package/.next/standalone/src/components/ui/sheet.tsx +140 -0
  89. package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
  90. package/.next/standalone/src/components/ui/switch.tsx +28 -0
  91. package/.next/standalone/src/components/ui/tabs.tsx +54 -0
  92. package/.next/standalone/src/components/ui/textarea.tsx +21 -0
  93. package/.next/standalone/src/components/ui/tip.tsx +23 -0
  94. package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
  95. package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
  96. package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
  97. package/.next/standalone/src/lib/app-runner.ts +231 -0
  98. package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
  99. package/.next/standalone/src/lib/config.ts +32 -0
  100. package/.next/standalone/src/lib/embeds/detect.ts +142 -0
  101. package/.next/standalone/src/lib/google/detect.ts +107 -0
  102. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
  103. package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
  104. package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
  105. package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
  106. package/.next/standalone/src/lib/root-dir.ts +35 -0
  107. package/.next/standalone/src/lib/toast.ts +9 -0
  108. package/.next/standalone/src/lib/utils.ts +6 -0
  109. package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
  110. package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
  111. package/.next/standalone/src/stores/editor-store.ts +244 -0
  112. package/.next/standalone/src/stores/tree-store.ts +52 -0
  113. package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
  114. package/.next/standalone/src/types/index.ts +47 -0
  115. package/.next/standalone/tailwind.config.ts +201 -0
  116. package/.next/standalone/tsconfig.json +41 -0
  117. package/README.md +283 -0
  118. package/bin/wiki-viewer.js +158 -0
  119. package/package.json +120 -0
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { Download, File, FolderOpen } from "lucide-react";
4
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ interface FileFallbackViewerProps {
8
+ path: string;
9
+ title: string;
10
+ }
11
+
12
+ export function FileFallbackViewer({ path }: FileFallbackViewerProps) {
13
+ const assetUrl = `/api/assets/${path}`;
14
+ const filename = path.split("/").pop() || path;
15
+ const ext = filename.includes(".")
16
+ ? filename.split(".").pop()?.toUpperCase()
17
+ : "";
18
+
19
+ const revealInFinder = async () => {
20
+ try {
21
+ await fetch("/api/system/reveal", {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({ path }),
25
+ });
26
+ } catch {
27
+ /* ignore */
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className="flex-1 flex flex-col overflow-hidden">
33
+ <ViewerToolbar path={path} badge={ext || undefined} />
34
+ <div className="flex-1 flex items-center justify-center">
35
+ <div className="flex flex-col items-center gap-4 text-center max-w-sm">
36
+ <div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
37
+ <File className="size-8 text-muted-foreground/50" />
38
+ </div>
39
+ <div className="space-y-1">
40
+ <p className="text-sm font-medium">{filename}</p>
41
+ <p className="text-xs text-muted-foreground">
42
+ This file type can&apos;t be previewed in Cabinet.
43
+ </p>
44
+ </div>
45
+ <div className="flex items-center gap-2">
46
+ <Button
47
+ variant="outline"
48
+ size="sm"
49
+ className="gap-2"
50
+ onClick={revealInFinder}
51
+ >
52
+ <FolderOpen className="h-4 w-4" />
53
+ Open in Finder
54
+ </Button>
55
+ <Button
56
+ variant="outline"
57
+ size="sm"
58
+ className="gap-2"
59
+ onClick={() => {
60
+ const a = document.createElement("a");
61
+ a.href = assetUrl;
62
+ a.download = filename;
63
+ a.click();
64
+ }}
65
+ >
66
+ <Download className="h-4 w-4" />
67
+ Download
68
+ </Button>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,235 @@
1
+ "use client";
2
+
3
+ import {
4
+ AppWindow,
5
+ FileAudio,
6
+ FileCode2,
7
+ FileImage,
8
+ FileSpreadsheet,
9
+ FileText,
10
+ FileVideo,
11
+ Folder,
12
+ Globe,
13
+ LayoutGrid,
14
+ List as ListIcon,
15
+ } from "lucide-react";
16
+ import { useMemo, useState } from "react";
17
+ import { useEditorStore } from "@/stores/editor-store";
18
+ import { useTreeStore } from "@/stores/tree-store";
19
+ import type { TreeNode } from "@/types";
20
+
21
+ type ViewMode = "list" | "gallery";
22
+
23
+ const VIEW_MODE_KEY = "kb-folder-view-mode";
24
+
25
+ function loadModePref(path: string): ViewMode | null {
26
+ if (typeof window === "undefined") return null;
27
+ try {
28
+ const raw = localStorage.getItem(VIEW_MODE_KEY);
29
+ if (!raw) return null;
30
+ const parsed = JSON.parse(raw) as Record<string, ViewMode>;
31
+ return parsed[path] ?? null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function saveModePref(path: string, mode: ViewMode) {
38
+ if (typeof window === "undefined") return;
39
+ try {
40
+ const raw = localStorage.getItem(VIEW_MODE_KEY);
41
+ const parsed = raw ? (JSON.parse(raw) as Record<string, ViewMode>) : {};
42
+ parsed[path] = mode;
43
+ localStorage.setItem(VIEW_MODE_KEY, JSON.stringify(parsed));
44
+ } catch {
45
+ // ignore quota / parse errors
46
+ }
47
+ }
48
+
49
+ function isImageChild(node: TreeNode): boolean {
50
+ return node.type === "image";
51
+ }
52
+
53
+ // A folder is "image-heavy" when it has enough images to make a gallery feel
54
+ // useful AND images dominate the child set. Tuned for moodboards and
55
+ // screenshot dumps without surprising mixed folders.
56
+ function isImageHeavy(children: TreeNode[]): boolean {
57
+ if (children.length === 0) return false;
58
+ const images = children.filter(isImageChild).length;
59
+ return images >= 4 && images / children.length >= 0.6;
60
+ }
61
+
62
+ function iconFor(node: TreeNode) {
63
+ switch (node.type) {
64
+ case "directory":
65
+ case "cabinet":
66
+ return Folder;
67
+ case "image":
68
+ return FileImage;
69
+ case "video":
70
+ return FileVideo;
71
+ case "audio":
72
+ return FileAudio;
73
+ case "code":
74
+ return FileCode2;
75
+ case "csv":
76
+ case "xlsx":
77
+ return FileSpreadsheet;
78
+ case "website":
79
+ return Globe;
80
+ case "app":
81
+ return AppWindow;
82
+ default:
83
+ return FileText;
84
+ }
85
+ }
86
+
87
+ function navigateTo(path: string) {
88
+ const { selectPage, expandPath } = useTreeStore.getState();
89
+ const parts = path.split("/");
90
+ for (let i = 1; i < parts.length; i++) {
91
+ expandPath(parts.slice(0, i).join("/"));
92
+ }
93
+ selectPage(path);
94
+ void useEditorStore.getState().loadPage(path);
95
+ }
96
+
97
+ interface FolderIndexProps {
98
+ folderPath: string;
99
+ entries: TreeNode[];
100
+ }
101
+
102
+ // The parent passes a `key={folderPath}` so this component remounts when the
103
+ // folder changes — that's why state can safely seed from the path on mount
104
+ // without a re-sync effect.
105
+ export function FolderIndex({ folderPath, entries }: FolderIndexProps) {
106
+ const imageHeavy = useMemo(() => isImageHeavy(entries), [entries]);
107
+
108
+ const [mode, setMode] = useState<ViewMode>(() => {
109
+ const saved = loadModePref(folderPath);
110
+ if (saved) return saved;
111
+ return imageHeavy ? "gallery" : "list";
112
+ });
113
+
114
+ const onSetMode = (next: ViewMode) => {
115
+ setMode(next);
116
+ saveModePref(folderPath, next);
117
+ };
118
+
119
+ // Sort: directories first, then by name. Stable so siblings stay grouped.
120
+ const sorted = useMemo(() => {
121
+ const copy = [...entries];
122
+ copy.sort((a, b) => {
123
+ const aDir = a.type === "directory" || a.type === "cabinet";
124
+ const bDir = b.type === "directory" || b.type === "cabinet";
125
+ if (aDir !== bDir) return aDir ? -1 : 1;
126
+ return a.name.localeCompare(b.name);
127
+ });
128
+ return copy;
129
+ }, [entries]);
130
+
131
+ return (
132
+ <div className="space-y-3">
133
+ <div className="flex items-center justify-between">
134
+ <p className="text-[11px] uppercase tracking-wider text-muted-foreground/70">
135
+ {sorted.length} {sorted.length === 1 ? "item" : "items"}
136
+ </p>
137
+ <div className="inline-flex items-center rounded-md border border-border p-0.5 text-[11px]">
138
+ <button
139
+ onClick={() => onSetMode("list")}
140
+ className={`flex items-center gap-1 px-2 py-1 rounded transition-colors ${
141
+ mode === "list"
142
+ ? "bg-accent text-foreground"
143
+ : "text-muted-foreground hover:text-foreground"
144
+ }`}
145
+ aria-pressed={mode === "list"}
146
+ title="List view"
147
+ >
148
+ <ListIcon className="h-3 w-3" />
149
+ List
150
+ </button>
151
+ <button
152
+ onClick={() => onSetMode("gallery")}
153
+ className={`flex items-center gap-1 px-2 py-1 rounded transition-colors ${
154
+ mode === "gallery"
155
+ ? "bg-accent text-foreground"
156
+ : "text-muted-foreground hover:text-foreground"
157
+ }`}
158
+ aria-pressed={mode === "gallery"}
159
+ title="Gallery view"
160
+ >
161
+ <LayoutGrid className="h-3 w-3" />
162
+ Gallery
163
+ </button>
164
+ </div>
165
+ </div>
166
+
167
+ {mode === "list" ? (
168
+ <ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
169
+ {sorted.map((child) => {
170
+ const Icon = iconFor(child);
171
+ const title = child.frontmatter?.title || child.name;
172
+ return (
173
+ <li key={child.path}>
174
+ <button
175
+ onClick={() => navigateTo(child.path)}
176
+ className="w-full flex items-center gap-3 px-3 py-2 text-left text-sm hover:bg-accent/50 transition-colors"
177
+ >
178
+ <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
179
+ <span className="flex-1 truncate">{title}</span>
180
+ <span className="text-[11px] text-muted-foreground/60 capitalize">
181
+ {child.type}
182
+ </span>
183
+ </button>
184
+ </li>
185
+ );
186
+ })}
187
+ </ul>
188
+ ) : (
189
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
190
+ {sorted.map((child) => {
191
+ const title = child.frontmatter?.title || child.name;
192
+ if (isImageChild(child)) {
193
+ return (
194
+ <button
195
+ key={child.path}
196
+ onClick={() => navigateTo(child.path)}
197
+ className="group flex flex-col gap-1.5 text-left"
198
+ title={title}
199
+ >
200
+ <div className="aspect-square w-full overflow-hidden rounded-md border border-border bg-muted">
201
+ <img
202
+ src={`/api/assets/${child.path}`}
203
+ alt={title}
204
+ loading="lazy"
205
+ className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
206
+ />
207
+ </div>
208
+ <span className="truncate text-[12px] text-muted-foreground group-hover:text-foreground">
209
+ {title}
210
+ </span>
211
+ </button>
212
+ );
213
+ }
214
+ const Icon = iconFor(child);
215
+ return (
216
+ <button
217
+ key={child.path}
218
+ onClick={() => navigateTo(child.path)}
219
+ className="group flex flex-col gap-1.5 text-left"
220
+ title={title}
221
+ >
222
+ <div className="aspect-square w-full flex items-center justify-center rounded-md border border-border bg-muted/40 group-hover:bg-muted transition-colors">
223
+ <Icon className="h-8 w-8 text-muted-foreground/60" />
224
+ </div>
225
+ <span className="truncate text-[12px] text-muted-foreground group-hover:text-foreground">
226
+ {title}
227
+ </span>
228
+ </button>
229
+ );
230
+ })}
231
+ </div>
232
+ )}
233
+ </div>
234
+ );
235
+ }
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { Info } from "lucide-react";
4
+ import { useMemo } from "react";
5
+ import {
6
+ detectGoogle,
7
+ type GoogleKind,
8
+ googleKindLabel,
9
+ } from "@/lib/google/detect";
10
+ import type { GoogleFrontmatter } from "@/types";
11
+ import { OfficeChrome } from "./office/office-chrome";
12
+
13
+ interface Props {
14
+ path: string;
15
+ title: string;
16
+ google: GoogleFrontmatter;
17
+ }
18
+
19
+ export function GoogleDocViewer({ path, title, google }: Props) {
20
+ const resolved = useMemo(() => detectGoogle(google.url || ""), [google.url]);
21
+ const embedUrl = google.embedUrl ?? resolved?.embedUrl ?? "";
22
+ const openUrl = resolved?.openUrl ?? google.url ?? "";
23
+ const kind: GoogleKind =
24
+ (google.kind as GoogleKind) ?? resolved?.kind ?? "docs";
25
+ const label = googleKindLabel(kind).toUpperCase();
26
+
27
+ if (!embedUrl) {
28
+ return (
29
+ <div className="flex-1 flex flex-col overflow-hidden">
30
+ <OfficeChrome path={path} title={title} extLabel="GOOGLE" hideFinder />
31
+ <div className="flex-1 flex items-center justify-center p-6">
32
+ <div className="max-w-md text-center space-y-2">
33
+ <p className="text-sm font-medium">No Google URL set</p>
34
+ <p className="text-xs text-muted-foreground">
35
+ Add a <code>google.url</code> field to this page&apos;s
36
+ frontmatter pointing to a Google Sheets, Slides, Docs or Forms
37
+ link.
38
+ </p>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <div className="flex-1 flex flex-col overflow-hidden">
47
+ <OfficeChrome
48
+ path={path}
49
+ title={title}
50
+ extLabel={label}
51
+ hideFinder
52
+ external={
53
+ openUrl ? { label: "Open in Google", href: openUrl } : undefined
54
+ }
55
+ />
56
+ <div className="px-4 py-1.5 border-b border-border bg-muted/30 text-[11px] text-muted-foreground flex items-center gap-1.5">
57
+ <Info className="h-3 w-3 shrink-0" />
58
+ Embed requires the doc&apos;s sharing to be{" "}
59
+ <strong className="text-foreground/80">Anyone with the link</strong> or{" "}
60
+ <strong className="text-foreground/80">Published to the web</strong>.
61
+ </div>
62
+ <div className="flex-1 relative bg-muted/30">
63
+ <iframe
64
+ key={embedUrl}
65
+ src={embedUrl}
66
+ className="absolute inset-0 w-full h-full border-0 bg-background"
67
+ allow="clipboard-write; fullscreen"
68
+ title={title}
69
+ />
70
+ </div>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { Download, ExternalLink } from "lucide-react";
4
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ interface ImageViewerProps {
8
+ path: string;
9
+ title: string;
10
+ }
11
+
12
+ export function ImageViewer({ path, title }: ImageViewerProps) {
13
+ const src = `/api/assets/${path}`;
14
+ const filename = path.split("/").pop() || path;
15
+ const ext = filename.includes(".")
16
+ ? filename.split(".").pop()?.toUpperCase()
17
+ : "IMG";
18
+
19
+ return (
20
+ <div className="flex-1 flex flex-col overflow-hidden">
21
+ <ViewerToolbar path={path} badge={ext}>
22
+ <Button
23
+ variant="ghost"
24
+ size="sm"
25
+ className="h-7 gap-1.5 text-xs"
26
+ onClick={() => {
27
+ const a = document.createElement("a");
28
+ a.href = src;
29
+ a.download = filename;
30
+ a.click();
31
+ }}
32
+ >
33
+ <Download className="h-3.5 w-3.5" />
34
+ Download
35
+ </Button>
36
+ <Button
37
+ variant="ghost"
38
+ size="sm"
39
+ className="h-7 gap-1.5 text-xs"
40
+ onClick={() => window.open(src, "_blank")}
41
+ >
42
+ <ExternalLink className="h-3.5 w-3.5" />
43
+ Open in new tab
44
+ </Button>
45
+ </ViewerToolbar>
46
+ <div className="flex-1 overflow-auto flex items-center justify-center bg-[#1a1a1a] p-8">
47
+ <img
48
+ src={src}
49
+ alt={title}
50
+ className="max-w-full max-h-full object-contain rounded-md shadow-lg"
51
+ style={{ imageRendering: "auto" }}
52
+ />
53
+ </div>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import { ExternalLink, Link as LinkIcon, Trash2 } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+
6
+ interface Props {
7
+ anchor: { top: number; left: number };
8
+ initialUrl?: string;
9
+ onCancel: () => void;
10
+ onApply: (url: string) => void;
11
+ onRemove?: () => void;
12
+ }
13
+
14
+ export function LinkPopover({
15
+ anchor,
16
+ initialUrl = "",
17
+ onCancel,
18
+ onApply,
19
+ onRemove,
20
+ }: Props) {
21
+ const [url, setUrl] = useState(initialUrl);
22
+
23
+ useEffect(() => {
24
+ const onKey = (e: KeyboardEvent) => {
25
+ if (e.key === "Escape") onCancel();
26
+ };
27
+ window.addEventListener("keydown", onKey);
28
+ return () => window.removeEventListener("keydown", onKey);
29
+ }, [onCancel]);
30
+
31
+ const apply = () => {
32
+ const trimmed = url.trim();
33
+ if (!trimmed) {
34
+ onCancel();
35
+ return;
36
+ }
37
+ onApply(trimmed);
38
+ };
39
+
40
+ return (
41
+ <div
42
+ className="absolute z-50 w-[340px] bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
43
+ style={{ top: anchor.top, left: anchor.left }}
44
+ onMouseDown={(e) => e.stopPropagation()}
45
+ >
46
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
47
+ <div className="flex items-center gap-1.5 text-[12px] font-medium">
48
+ <LinkIcon className="w-3.5 h-3.5" />
49
+ {initialUrl ? "Edit link" : "Add link"}
50
+ </div>
51
+ <button
52
+ type="button"
53
+ onClick={onCancel}
54
+ className="text-[11px] text-muted-foreground hover:text-foreground"
55
+ >
56
+ Cancel
57
+ </button>
58
+ </div>
59
+
60
+ <div className="p-3 space-y-2">
61
+ <input
62
+ type="url"
63
+ autoFocus
64
+ value={url}
65
+ placeholder="https://example.com"
66
+ onChange={(e) => setUrl(e.target.value)}
67
+ onKeyDown={(e) => {
68
+ if (e.key === "Enter") {
69
+ e.preventDefault();
70
+ apply();
71
+ }
72
+ }}
73
+ className="w-full bg-background border border-border rounded-md px-2.5 py-1.5 text-[12px] focus:outline-none focus:ring-2 focus:ring-primary/30"
74
+ />
75
+ <div className="flex items-center gap-2">
76
+ <button
77
+ type="button"
78
+ onClick={apply}
79
+ disabled={!url.trim()}
80
+ className="flex-1 py-1.5 text-[12px] rounded-md bg-primary text-primary-foreground disabled:opacity-50 hover:bg-primary/90"
81
+ >
82
+ {initialUrl ? "Update" : "Add link"}
83
+ </button>
84
+ {initialUrl && (
85
+ <>
86
+ <a
87
+ href={initialUrl}
88
+ target="_blank"
89
+ rel="noreferrer noopener"
90
+ className="p-1.5 rounded-md border border-border hover:bg-accent text-muted-foreground hover:text-foreground"
91
+ title="Open link"
92
+ >
93
+ <ExternalLink className="w-3.5 h-3.5" />
94
+ </a>
95
+ {onRemove && (
96
+ <button
97
+ type="button"
98
+ onClick={onRemove}
99
+ className="p-1.5 rounded-md border border-border hover:bg-accent text-destructive"
100
+ title="Remove link"
101
+ >
102
+ <Trash2 className="w-3.5 h-3.5" />
103
+ </button>
104
+ )}
105
+ </>
106
+ )}
107
+ </div>
108
+ </div>
109
+ </div>
110
+ );
111
+ }