veslx 0.0.1

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 (83) hide show
  1. package/README.md +3 -0
  2. package/bin/lib/import-config.ts +13 -0
  3. package/bin/lib/init.ts +31 -0
  4. package/bin/lib/serve.ts +35 -0
  5. package/bin/lib/start.ts +40 -0
  6. package/bin/lib/stop.ts +24 -0
  7. package/bin/vesl.ts +41 -0
  8. package/components.json +20 -0
  9. package/eslint.config.js +23 -0
  10. package/index.html +17 -0
  11. package/package.json +89 -0
  12. package/plugin/README.md +21 -0
  13. package/plugin/package.json +26 -0
  14. package/plugin/src/cli.ts +30 -0
  15. package/plugin/src/client.tsx +224 -0
  16. package/plugin/src/lib.ts +268 -0
  17. package/plugin/src/plugin.ts +109 -0
  18. package/postcss.config.js +5 -0
  19. package/public/logo_dark.png +0 -0
  20. package/public/logo_light.png +0 -0
  21. package/src/App.tsx +21 -0
  22. package/src/components/front-matter.tsx +53 -0
  23. package/src/components/gallery/components/figure-caption.tsx +15 -0
  24. package/src/components/gallery/components/figure-header.tsx +20 -0
  25. package/src/components/gallery/components/lightbox.tsx +106 -0
  26. package/src/components/gallery/components/loading-image.tsx +48 -0
  27. package/src/components/gallery/hooks/use-gallery-images.ts +103 -0
  28. package/src/components/gallery/hooks/use-lightbox.ts +40 -0
  29. package/src/components/gallery/index.tsx +134 -0
  30. package/src/components/gallery/lib/render-math-in-text.tsx +47 -0
  31. package/src/components/header.tsx +68 -0
  32. package/src/components/index.ts +5 -0
  33. package/src/components/loading.tsx +16 -0
  34. package/src/components/mdx-components.tsx +163 -0
  35. package/src/components/mode-toggle.tsx +44 -0
  36. package/src/components/page-error.tsx +59 -0
  37. package/src/components/parameter-badge.tsx +78 -0
  38. package/src/components/parameter-table.tsx +420 -0
  39. package/src/components/post-list.tsx +148 -0
  40. package/src/components/running-bar.tsx +21 -0
  41. package/src/components/runtime-mdx.tsx +82 -0
  42. package/src/components/slide.tsx +11 -0
  43. package/src/components/theme-provider.tsx +6 -0
  44. package/src/components/ui/badge.tsx +36 -0
  45. package/src/components/ui/breadcrumb.tsx +115 -0
  46. package/src/components/ui/button.tsx +56 -0
  47. package/src/components/ui/card.tsx +79 -0
  48. package/src/components/ui/carousel.tsx +260 -0
  49. package/src/components/ui/dropdown-menu.tsx +198 -0
  50. package/src/components/ui/input.tsx +22 -0
  51. package/src/components/ui/kbd.tsx +22 -0
  52. package/src/components/ui/select.tsx +158 -0
  53. package/src/components/ui/separator.tsx +29 -0
  54. package/src/components/ui/shadcn-io/code-block/index.tsx +620 -0
  55. package/src/components/ui/shadcn-io/code-block/server.tsx +63 -0
  56. package/src/components/ui/sheet.tsx +140 -0
  57. package/src/components/ui/sidebar.tsx +771 -0
  58. package/src/components/ui/skeleton.tsx +15 -0
  59. package/src/components/ui/spinner.tsx +16 -0
  60. package/src/components/ui/tooltip.tsx +28 -0
  61. package/src/components/welcome.tsx +21 -0
  62. package/src/hooks/use-key-bindings.ts +72 -0
  63. package/src/hooks/use-mobile.tsx +19 -0
  64. package/src/index.css +279 -0
  65. package/src/lib/constants.ts +10 -0
  66. package/src/lib/format-date.tsx +6 -0
  67. package/src/lib/format-file-size.ts +10 -0
  68. package/src/lib/parameter-utils.ts +134 -0
  69. package/src/lib/utils.ts +6 -0
  70. package/src/main.tsx +10 -0
  71. package/src/pages/home.tsx +39 -0
  72. package/src/pages/post.tsx +65 -0
  73. package/src/pages/slides.tsx +173 -0
  74. package/tailwind.config.js +136 -0
  75. package/test-content/.vesl.json +49 -0
  76. package/test-content/README.md +33 -0
  77. package/test-content/test-post/README.mdx +7 -0
  78. package/test-content/test-slides/SLIDES.mdx +8 -0
  79. package/tsconfig.app.json +32 -0
  80. package/tsconfig.json +15 -0
  81. package/tsconfig.node.json +25 -0
  82. package/vesl.config.ts +4 -0
  83. package/vite.config.ts +54 -0
@@ -0,0 +1,268 @@
1
+ import { readdir, writeFile, stat, readFile } from 'fs/promises';
2
+ import { join, relative, basename, extname } from 'path';
3
+ import matter from 'gray-matter';
4
+
5
+ export const GITIGNORE_FILENAME = '.gitignore'
6
+
7
+ export type FileEntry = {
8
+ type: 'file';
9
+ name: string;
10
+ path: string;
11
+ size: number;
12
+ frontmatter?: {
13
+ title?: string;
14
+ description?: string;
15
+ date?: string;
16
+ };
17
+ }
18
+
19
+ export type DirectoryEntry = {
20
+ type: 'directory';
21
+ name: string;
22
+ path: string;
23
+ children: (FileEntry | DirectoryEntry)[];
24
+ }
25
+
26
+ const MARKDOWN_EXTENSIONS = ['.md', '.mdx', '.markdown'];
27
+
28
+ /**
29
+ * Check if a file is a markdown file
30
+ */
31
+ function isMarkdownFile(filename: string): boolean {
32
+ const ext = extname(filename).toLowerCase();
33
+ return MARKDOWN_EXTENSIONS.includes(ext);
34
+ }
35
+
36
+ /**
37
+ * Parse frontmatter from a markdown file
38
+ * Returns undefined if no frontmatter exists or file is not markdown
39
+ */
40
+ async function parseFrontmatter(filePath: string): Promise<Record<string, unknown> | undefined> {
41
+ try {
42
+ const content = await readFile(filePath, 'utf-8');
43
+ const { data } = matter(content);
44
+ // Only return frontmatter if it has content
45
+ if (Object.keys(data).length > 0) {
46
+ return data;
47
+ }
48
+ return undefined;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Check if a path is a directory
56
+ */
57
+ async function isDirectory(path: string): Promise<boolean> {
58
+ try {
59
+ const stats = await stat(path);
60
+ return stats.isDirectory();
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Parse .gitignore file and return array of patterns
68
+ */
69
+ async function parseGitignore(gitignorePath: string): Promise<string[]> {
70
+ try {
71
+ const content = await readFile(gitignorePath, 'utf-8');
72
+ return content
73
+ .split('\n')
74
+ .map(line => line.trim())
75
+ .filter(line => line && !line.startsWith('#')); // Skip empty lines and comments
76
+ } catch {
77
+ return []; // Return empty array if .gitignore doesn't exist
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Convert gitignore pattern to regex-like matching function
83
+ * Supports:
84
+ * - *.ext (any file ending with .ext)
85
+ * - dir/ (directory anywhere in tree)
86
+ * - dir/** (directory and all contents)
87
+ * - **\/pattern (pattern anywhere)
88
+ */
89
+ function createGitignoreMatcher(patterns: string[]): (relativePath: string, isDir: boolean) => boolean {
90
+ return (relativePath: string, isDir: boolean) => {
91
+ const pathParts = relativePath.split('/');
92
+ const filename = basename(relativePath);
93
+
94
+ for (const pattern of patterns) {
95
+ // Handle directory patterns (ending with /)
96
+ if (pattern.endsWith('/')) {
97
+ const dirPattern = pattern.slice(0, -1);
98
+ if (isDir) {
99
+ // Match if any part of the path matches the directory name
100
+ if (pathParts.includes(dirPattern) || relativePath === dirPattern || relativePath.startsWith(dirPattern + '/')) {
101
+ return true;
102
+ }
103
+ }
104
+ continue;
105
+ }
106
+
107
+ // Handle ** patterns
108
+ if (pattern.includes('**')) {
109
+ const regexPattern = pattern
110
+ .replace(/\./g, '\\.')
111
+ .replace(/\*\*/g, '.*')
112
+ .replace(/\*/g, '[^/]*');
113
+ const regex = new RegExp(`^${regexPattern}$`);
114
+ if (regex.test(relativePath)) {
115
+ return true;
116
+ }
117
+ continue;
118
+ }
119
+
120
+ // Handle simple wildcard patterns (*.ext)
121
+ if (pattern.includes('*')) {
122
+ const regexPattern = pattern
123
+ .replace(/\./g, '\\.')
124
+ .replace(/\*/g, '[^/]*');
125
+ const regex = new RegExp(`^${regexPattern}$`);
126
+
127
+ // Check if the pattern matches the full path or just the filename
128
+ if (regex.test(relativePath) || regex.test(filename)) {
129
+ return true;
130
+ }
131
+ continue;
132
+ }
133
+
134
+ // Exact match - check both full path and filename
135
+ if (relativePath === pattern || filename === pattern) {
136
+ return true;
137
+ }
138
+
139
+ // Prefix match for directories
140
+ if (relativePath.startsWith(pattern + '/')) {
141
+ return true;
142
+ }
143
+ }
144
+
145
+ return false;
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Recursively scan directory and build flat index
151
+ */
152
+ async function scanDirectory(
153
+ dirPath: string,
154
+ rootPath: string,
155
+ shouldIgnore: (relativePath: string, isDir: boolean) => boolean,
156
+ depth: number = 0
157
+ ): Promise<DirectoryEntry> {
158
+ const entries = await readdir(dirPath);
159
+ const children: (FileEntry | DirectoryEntry)[] = [];
160
+
161
+ for (const entry of entries) {
162
+ // Skip hidden files and the index file itself
163
+ if (entry.startsWith('.')) {
164
+ continue;
165
+ }
166
+
167
+ const fullPath = join(dirPath, entry);
168
+ const relativePath = relative(rootPath, fullPath);
169
+ const isDir = await isDirectory(fullPath);
170
+
171
+ // Check if this path should be ignored
172
+ if (shouldIgnore(relativePath, isDir)) {
173
+ continue;
174
+ }
175
+
176
+ if (isDir) {
177
+ // Recursively scan subdirectories
178
+ const subDir = await scanDirectory(fullPath, rootPath, shouldIgnore, depth + 1);
179
+ children.push(subDir);
180
+ } else {
181
+ // Add file entry
182
+ const stats = await stat(fullPath);
183
+ const fileEntry: FileEntry = {
184
+ type: 'file',
185
+ name: entry,
186
+ path: relativePath,
187
+ size: stats.size,
188
+ };
189
+
190
+ // Parse frontmatter for markdown files
191
+ if (isMarkdownFile(entry)) {
192
+ const frontmatter = await parseFrontmatter(fullPath);
193
+ if (frontmatter) {
194
+ fileEntry.frontmatter = frontmatter;
195
+ }
196
+ }
197
+
198
+ children.push(fileEntry);
199
+ }
200
+ }
201
+
202
+ return {
203
+ type: 'directory',
204
+ name: basename(dirPath),
205
+ path: relative(rootPath, dirPath) || '.',
206
+ children,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Build content for a single target
212
+ */
213
+ async function buildTarget(target: string): Promise<void> {
214
+ console.log(`${'-'.repeat(80)}`);
215
+ console.log(`Building: ${target}`);
216
+ console.log(`${'-'.repeat(80)}`);
217
+
218
+ const gitignorePath = join(target, GITIGNORE_FILENAME);
219
+ const ignorePatterns = await parseGitignore(gitignorePath);
220
+ const shouldIgnore = createGitignoreMatcher(ignorePatterns);
221
+
222
+ if (ignorePatterns.length > 0) {
223
+ console.log(` Found .gitignore with ${ignorePatterns.length} pattern${ignorePatterns.length !== 1 ? 's' : ''}`);
224
+ } else {
225
+ console.log(` No .gitignore found or empty\n`);
226
+ }
227
+
228
+ const index = await scanDirectory(target, target, shouldIgnore);
229
+
230
+ // Count files and directories
231
+ function countEntries(entry: DirectoryEntry): { files: number; dirs: number } {
232
+ let files = 0;
233
+ let dirs = 0;
234
+
235
+ for (const child of entry.children) {
236
+ if (child.type === 'file') {
237
+ files++;
238
+ } else {
239
+ dirs++;
240
+ const counts = countEntries(child);
241
+ files += counts.files;
242
+ dirs += counts.dirs;
243
+ }
244
+ }
245
+
246
+ return { files, dirs };
247
+ }
248
+
249
+ const counts = countEntries(index);
250
+ console.log(` Found ${counts.dirs} directories and ${counts.files} files`);
251
+
252
+ // Write output
253
+ const outputFile = join(target, '.veslx.json');
254
+ await writeFile(outputFile, JSON.stringify(index, null, 2));
255
+
256
+ console.log(` Generated ${outputFile}`);
257
+ }
258
+
259
+ /**
260
+ * Build content for all configured targets
261
+ */
262
+ async function buildAll(targets: string[]): Promise<void> {
263
+ for (const target of targets) {
264
+ await buildTarget(target);
265
+ }
266
+ }
267
+
268
+ export { buildAll };
@@ -0,0 +1,109 @@
1
+ import { type Plugin, type Connect } from 'vite'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+ import { buildAll } from './lib'
5
+ import chokidar from 'chokidar'
6
+ import type { IncomingMessage, ServerResponse } from 'http'
7
+
8
+ export default function contentPlugin(dir: string): Plugin {
9
+
10
+ if (!dir) {
11
+ throw new Error('Content directory must be specified.')
12
+ }
13
+
14
+ const buildFn = () => buildAll([dir])
15
+
16
+ let watchers: chokidar.FSWatcher[] = []
17
+
18
+ // Server middleware for serving content files
19
+ const urlToDir = new Map<string, string>()
20
+
21
+ urlToDir.set('/raw', dir)
22
+
23
+ const middleware: Connect.NextHandleFunction = (req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => {
24
+ // Check if URL matches any registered content directory
25
+ for (const [urlBase, contentDir] of urlToDir.entries()) {
26
+ if (req.url?.startsWith(urlBase + '/')) {
27
+ const relativePath = req.url.slice(urlBase.length + 1)
28
+ const filePath = path.join(contentDir, relativePath)
29
+
30
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
31
+ res.setHeader('Access-Control-Allow-Origin', '*')
32
+ const ext = path.extname(filePath).toLowerCase()
33
+
34
+ // Set appropriate content types
35
+ const contentTypes: Record<string, string> = {
36
+ '.png': 'image/png',
37
+ '.jpg': 'image/jpeg',
38
+ '.jpeg': 'image/jpeg',
39
+ '.gif': 'image/gif',
40
+ '.svg': 'image/svg+xml',
41
+ '.json': 'application/json',
42
+ '.md': 'text/markdown',
43
+ '.yaml': 'text/yaml',
44
+ '.yml': 'text/yaml',
45
+ '.npz': 'application/octet-stream',
46
+ }
47
+
48
+ if (contentTypes[ext]) {
49
+ res.setHeader('Content-Type', contentTypes[ext])
50
+ }
51
+
52
+ return fs.createReadStream(filePath).pipe(res)
53
+ }
54
+ }
55
+ }
56
+ next()
57
+ }
58
+
59
+ return {
60
+ name: 'content',
61
+ async buildStart() {
62
+ await buildFn()
63
+ },
64
+ configureServer(server) {
65
+ // Add middleware for serving content files
66
+ server.middlewares.use(middleware)
67
+
68
+ // Watch all content directories and rebuild on changes
69
+ watchers = [dir].map(dir => {
70
+ const watcher = chokidar.watch(dir, {
71
+ ignored: (path: string) => path.endsWith('.veslx.json'),
72
+ persistent: true,
73
+ })
74
+
75
+ watcher.on('change', async () => {
76
+ const runningFilePath = path.join(dir, '.running')
77
+ if (!fs.existsSync(runningFilePath)) {
78
+ await buildFn()
79
+ server.ws.send({
80
+ type: 'full-reload',
81
+ path: '*',
82
+ })
83
+ }
84
+ })
85
+
86
+ watcher.on('unlink', async (filePath) => {
87
+ if (path.basename(filePath) === '.running') {
88
+ await buildFn()
89
+ server.ws.send({
90
+ type: 'full-reload',
91
+ path: '*',
92
+ })
93
+ }
94
+ })
95
+
96
+ return watcher
97
+ })
98
+
99
+ },
100
+ configurePreviewServer(server) {
101
+ // Add middleware for preview server too
102
+ server.middlewares.use(middleware)
103
+ },
104
+ async buildEnd() {
105
+ await Promise.all(watchers.map(w => w.close()))
106
+ watchers = []
107
+ },
108
+ }
109
+ }
@@ -0,0 +1,5 @@
1
+ export default {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
Binary file
Binary file
package/src/App.tsx ADDED
@@ -0,0 +1,21 @@
1
+ import { BrowserRouter, Routes, Route } from "react-router-dom"
2
+ import { ThemeProvider } from "./components/theme-provider"
3
+ import { Home } from "./pages/home"
4
+ import { Post } from "./pages/post"
5
+ import { SlidesPage } from "./pages/slides"
6
+
7
+ function App() {
8
+ return (
9
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
10
+ <BrowserRouter>
11
+ <Routes>
12
+ <Route path=":path/SLIDES.mdx" element={<SlidesPage />} />
13
+ <Route path=":path/README.mdx" element={<Post />} />
14
+ <Route path="/*" element={<Home />} />
15
+ </Routes>
16
+ </BrowserRouter>
17
+ </ThemeProvider>
18
+ )
19
+ }
20
+
21
+ export default App
@@ -0,0 +1,53 @@
1
+ import { formatDate } from "@/lib/format-date"
2
+ import { Presentation } from "lucide-react"
3
+ import { FileEntry } from "plugin/src/lib"
4
+ import { Link } from "react-router-dom"
5
+
6
+ export function FrontMatter({
7
+ title,
8
+ date,
9
+ description,
10
+ slides,
11
+ }: {
12
+ title?: string
13
+ date?: string
14
+ description?: string
15
+ slides?: FileEntry | null
16
+ }){
17
+
18
+ return (
19
+ <div>
20
+ {title && (
21
+ <header className="not-prose flex flex-col gap-2 mb-8 pt-4">
22
+ <h1 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
23
+ {title}
24
+ </h1>
25
+
26
+ {/* Meta line */}
27
+ <div className="flex flex-wrap items-center gap-3 text-muted-foreground">
28
+ {date && (
29
+ <time className="font-mono text-xs bg-muted px-2 py-0.5 rounded">
30
+ {formatDate(new Date(date as string))}
31
+ </time>
32
+ )}
33
+ {slides && (
34
+ <Link
35
+ to={`/${slides.path}`}
36
+ className="font-mono text-xs px-2 py-0.5 rounded flex items-center gap-1"
37
+ >
38
+ <Presentation className="h-3.5 w-3.5" />
39
+ <span>slides</span>
40
+ </Link>
41
+ )}
42
+ </div>
43
+
44
+ {description && (
45
+ <div className="flex flex-wrap text-sm items-center gap-3 text-muted-foreground">
46
+ {description}
47
+ </div>
48
+ )}
49
+ </header>
50
+ )}
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,15 @@
1
+ import { renderMathInText } from "../lib/render-math-in-text";
2
+
3
+ export function FigureCaption({ caption, label }: { caption?: string; label?: string }) {
4
+ if (!caption && !label) return null;
5
+
6
+ return (
7
+ <div className="mx-auto max-w-md">
8
+ <div className="text-sm text-muted-foreground leading-relaxed text-left">
9
+ {label && <span className="font-medium text-foreground">{label}</span>}
10
+ {label && caption && <span className="mx-1">—</span>}
11
+ {caption && renderMathInText(caption)}
12
+ </div>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,20 @@
1
+ import { renderMathInText } from "../lib/render-math-in-text";
2
+
3
+ export function FigureHeader({ title, subtitle }: { title?: string; subtitle?: string }) {
4
+ if (!title && !subtitle) return null;
5
+
6
+ return (
7
+ <div className="mx-auto max-w-md">
8
+ {title && (
9
+ <h3 className="text-sm md:text-base font-medium tracking-tight text-foreground text-left">
10
+ {renderMathInText(title)}
11
+ </h3>
12
+ )}
13
+ {subtitle && (
14
+ <p className="text-sm text-muted-foreground leading-relaxed text-left mt-1">
15
+ {renderMathInText(subtitle)}
16
+ </p>
17
+ )}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,106 @@
1
+ import { createPortal } from "react-dom";
2
+ import { X, ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { FULLSCREEN_DATA_ATTR } from "@/lib/constants";
4
+
5
+ export interface LightboxImage {
6
+ src: string;
7
+ label: string;
8
+ }
9
+
10
+ export interface LightboxProps {
11
+ images: LightboxImage[];
12
+ selectedIndex: number;
13
+ onClose: () => void;
14
+ onPrevious: () => void;
15
+ onNext: () => void;
16
+ showNavigation?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Fullscreen lightbox component for viewing images
21
+ */
22
+ export function Lightbox({
23
+ images,
24
+ selectedIndex,
25
+ onClose,
26
+ onPrevious,
27
+ onNext,
28
+ showNavigation = true,
29
+ }: LightboxProps) {
30
+ const current = images[selectedIndex];
31
+
32
+ return createPortal(
33
+ <div
34
+ className="fixed inset-0 z-[9999] bg-background"
35
+ onClick={onClose}
36
+ {...{ [FULLSCREEN_DATA_ATTR]: "true" }}
37
+ style={{ top: 0, left: 0, right: 0, bottom: 0 }}
38
+ >
39
+ {/* Top bar */}
40
+ <div
41
+ className="fixed top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-3 bg-background/80 backdrop-blur-sm"
42
+ onClick={(e) => e.stopPropagation()}
43
+ >
44
+ <div className="font-mono text-xs text-muted-foreground tabular-nums">
45
+ {String(selectedIndex + 1).padStart(2, '0')} / {String(images.length).padStart(2, '0')}
46
+ </div>
47
+ <button
48
+ onClick={onClose}
49
+ className="p-2 text-muted-foreground hover:text-foreground transition-colors"
50
+ aria-label="Close"
51
+ >
52
+ <X className="h-5 w-5" />
53
+ </button>
54
+ </div>
55
+
56
+ {/* Navigation: Previous */}
57
+ {showNavigation && selectedIndex > 0 && (
58
+ <button
59
+ onClick={(e) => {
60
+ e.stopPropagation();
61
+ onPrevious();
62
+ }}
63
+ className="fixed left-4 top-1/2 -translate-y-1/2 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
64
+ aria-label="Previous image"
65
+ >
66
+ <ChevronLeft className="h-8 w-8" />
67
+ </button>
68
+ )}
69
+
70
+ {/* Navigation: Next */}
71
+ {showNavigation && selectedIndex < images.length - 1 && (
72
+ <button
73
+ onClick={(e) => {
74
+ e.stopPropagation();
75
+ onNext();
76
+ }}
77
+ className="fixed right-4 top-1/2 -translate-y-1/2 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
78
+ aria-label="Next image"
79
+ >
80
+ <ChevronRight className="h-8 w-8" />
81
+ </button>
82
+ )}
83
+
84
+ {/* Main image */}
85
+ <div className="fixed inset-0 flex items-center justify-center p-16">
86
+ <img
87
+ src={current.src}
88
+ alt={current.label}
89
+ className="max-w-full max-h-full object-contain"
90
+ onClick={(e) => e.stopPropagation()}
91
+ />
92
+ </div>
93
+
94
+ {/* Caption */}
95
+ <div
96
+ className="fixed bottom-0 left-0 right-0 z-10 p-4 text-center bg-background/80 backdrop-blur-sm"
97
+ onClick={(e) => e.stopPropagation()}
98
+ >
99
+ <span className="font-mono text-xs text-muted-foreground">
100
+ {current.label}
101
+ </span>
102
+ </div>
103
+ </div>,
104
+ document.body
105
+ );
106
+ }
@@ -0,0 +1,48 @@
1
+ import { useState, ImgHTMLAttributes } from "react";
2
+ import { Image } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export function LoadingImage({
6
+ className,
7
+ wrapperClassName,
8
+ ...props
9
+ }: ImgHTMLAttributes<HTMLImageElement> & { wrapperClassName?: string }) {
10
+ const [isLoading, setIsLoading] = useState(true);
11
+ const [hasError, setHasError] = useState(false);
12
+
13
+ return (
14
+ <div className={cn("relative", wrapperClassName)}>
15
+ {isLoading && !hasError && (
16
+ <div className="absolute inset-0 bg-muted/30 animate-pulse flex items-center justify-center">
17
+ <div className="w-8 h-8 border border-border/50 rounded-sm" />
18
+ </div>
19
+ )}
20
+ {hasError && (
21
+ <div className="absolute inset-0 bg-muted/20 flex items-center justify-center">
22
+ <div className="text-center">
23
+ <Image className="h-5 w-5 text-muted-foreground/40 mx-auto" />
24
+ <span className="text-xs text-muted-foreground/40 mt-1.5 block font-mono">failed</span>
25
+ </div>
26
+ </div>
27
+ )}
28
+ <img
29
+ {...props}
30
+ className={cn(
31
+ className,
32
+ "transition-opacity duration-500 ease-out-expo",
33
+ isLoading && "opacity-0",
34
+ hasError && "opacity-0"
35
+ )}
36
+ onLoad={(e) => {
37
+ setIsLoading(false);
38
+ props.onLoad?.(e);
39
+ }}
40
+ onError={(e) => {
41
+ setIsLoading(false);
42
+ setHasError(true);
43
+ props.onError?.(e);
44
+ }}
45
+ />
46
+ </div>
47
+ );
48
+ }