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.
- package/README.md +3 -0
- package/bin/lib/import-config.ts +13 -0
- package/bin/lib/init.ts +31 -0
- package/bin/lib/serve.ts +35 -0
- package/bin/lib/start.ts +40 -0
- package/bin/lib/stop.ts +24 -0
- package/bin/vesl.ts +41 -0
- package/components.json +20 -0
- package/eslint.config.js +23 -0
- package/index.html +17 -0
- package/package.json +89 -0
- package/plugin/README.md +21 -0
- package/plugin/package.json +26 -0
- package/plugin/src/cli.ts +30 -0
- package/plugin/src/client.tsx +224 -0
- package/plugin/src/lib.ts +268 -0
- package/plugin/src/plugin.ts +109 -0
- package/postcss.config.js +5 -0
- package/public/logo_dark.png +0 -0
- package/public/logo_light.png +0 -0
- package/src/App.tsx +21 -0
- package/src/components/front-matter.tsx +53 -0
- package/src/components/gallery/components/figure-caption.tsx +15 -0
- package/src/components/gallery/components/figure-header.tsx +20 -0
- package/src/components/gallery/components/lightbox.tsx +106 -0
- package/src/components/gallery/components/loading-image.tsx +48 -0
- package/src/components/gallery/hooks/use-gallery-images.ts +103 -0
- package/src/components/gallery/hooks/use-lightbox.ts +40 -0
- package/src/components/gallery/index.tsx +134 -0
- package/src/components/gallery/lib/render-math-in-text.tsx +47 -0
- package/src/components/header.tsx +68 -0
- package/src/components/index.ts +5 -0
- package/src/components/loading.tsx +16 -0
- package/src/components/mdx-components.tsx +163 -0
- package/src/components/mode-toggle.tsx +44 -0
- package/src/components/page-error.tsx +59 -0
- package/src/components/parameter-badge.tsx +78 -0
- package/src/components/parameter-table.tsx +420 -0
- package/src/components/post-list.tsx +148 -0
- package/src/components/running-bar.tsx +21 -0
- package/src/components/runtime-mdx.tsx +82 -0
- package/src/components/slide.tsx +11 -0
- package/src/components/theme-provider.tsx +6 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/breadcrumb.tsx +115 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/carousel.tsx +260 -0
- package/src/components/ui/dropdown-menu.tsx +198 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/kbd.tsx +22 -0
- package/src/components/ui/select.tsx +158 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/shadcn-io/code-block/index.tsx +620 -0
- package/src/components/ui/shadcn-io/code-block/server.tsx +63 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +771 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/components/welcome.tsx +21 -0
- package/src/hooks/use-key-bindings.ts +72 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/index.css +279 -0
- package/src/lib/constants.ts +10 -0
- package/src/lib/format-date.tsx +6 -0
- package/src/lib/format-file-size.ts +10 -0
- package/src/lib/parameter-utils.ts +134 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/pages/home.tsx +39 -0
- package/src/pages/post.tsx +65 -0
- package/src/pages/slides.tsx +173 -0
- package/tailwind.config.js +136 -0
- package/test-content/.vesl.json +49 -0
- package/test-content/README.md +33 -0
- package/test-content/test-post/README.mdx +7 -0
- package/test-content/test-slides/SLIDES.mdx +8 -0
- package/tsconfig.app.json +32 -0
- package/tsconfig.json +15 -0
- package/tsconfig.node.json +25 -0
- package/vesl.config.ts +4 -0
- 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
|
+
}
|
|
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
|
+
}
|