veslx 0.1.14 → 0.1.15

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 (82) hide show
  1. package/README.md +262 -55
  2. package/bin/lib/build.ts +65 -13
  3. package/bin/lib/import-config.ts +10 -9
  4. package/bin/lib/init.ts +21 -22
  5. package/bin/lib/serve.ts +66 -12
  6. package/bin/veslx.ts +2 -2
  7. package/dist/client/App.js +3 -9
  8. package/dist/client/App.js.map +1 -1
  9. package/dist/client/components/front-matter.js +11 -25
  10. package/dist/client/components/front-matter.js.map +1 -1
  11. package/dist/client/components/gallery/components/figure-caption.js +6 -4
  12. package/dist/client/components/gallery/components/figure-caption.js.map +1 -1
  13. package/dist/client/components/gallery/components/figure-header.js +3 -3
  14. package/dist/client/components/gallery/components/figure-header.js.map +1 -1
  15. package/dist/client/components/gallery/components/lightbox.js +13 -13
  16. package/dist/client/components/gallery/components/lightbox.js.map +1 -1
  17. package/dist/client/components/gallery/components/loading-image.js +11 -10
  18. package/dist/client/components/gallery/components/loading-image.js.map +1 -1
  19. package/dist/client/components/gallery/hooks/use-gallery-images.js +31 -7
  20. package/dist/client/components/gallery/hooks/use-gallery-images.js.map +1 -1
  21. package/dist/client/components/gallery/index.js +22 -15
  22. package/dist/client/components/gallery/index.js.map +1 -1
  23. package/dist/client/components/header.js +5 -3
  24. package/dist/client/components/header.js.map +1 -1
  25. package/dist/client/components/mdx-components.js +42 -8
  26. package/dist/client/components/mdx-components.js.map +1 -1
  27. package/dist/client/components/post-list.js +97 -90
  28. package/dist/client/components/post-list.js.map +1 -1
  29. package/dist/client/components/running-bar.js +1 -1
  30. package/dist/client/components/running-bar.js.map +1 -1
  31. package/dist/client/components/slide.js +18 -0
  32. package/dist/client/components/slide.js.map +1 -0
  33. package/dist/client/components/slides-renderer.js +7 -71
  34. package/dist/client/components/slides-renderer.js.map +1 -1
  35. package/dist/client/hooks/use-mdx-content.js +55 -9
  36. package/dist/client/hooks/use-mdx-content.js.map +1 -1
  37. package/dist/client/main.js +1 -0
  38. package/dist/client/main.js.map +1 -1
  39. package/dist/client/pages/content-router.js +19 -0
  40. package/dist/client/pages/content-router.js.map +1 -0
  41. package/dist/client/pages/home.js +11 -7
  42. package/dist/client/pages/home.js.map +1 -1
  43. package/dist/client/pages/post.js +8 -20
  44. package/dist/client/pages/post.js.map +1 -1
  45. package/dist/client/pages/slides.js +62 -86
  46. package/dist/client/pages/slides.js.map +1 -1
  47. package/dist/client/plugin/src/client.js +58 -96
  48. package/dist/client/plugin/src/client.js.map +1 -1
  49. package/dist/client/plugin/src/directory-tree.js +111 -0
  50. package/dist/client/plugin/src/directory-tree.js.map +1 -0
  51. package/index.html +1 -1
  52. package/package.json +27 -15
  53. package/plugin/src/client.tsx +64 -116
  54. package/plugin/src/directory-tree.ts +171 -0
  55. package/plugin/src/lib.ts +6 -249
  56. package/plugin/src/plugin.ts +93 -50
  57. package/plugin/src/remark-slides.ts +100 -0
  58. package/plugin/src/types.ts +22 -0
  59. package/src/App.tsx +3 -6
  60. package/src/components/front-matter.tsx +14 -29
  61. package/src/components/gallery/components/figure-caption.tsx +15 -7
  62. package/src/components/gallery/components/figure-header.tsx +3 -3
  63. package/src/components/gallery/components/lightbox.tsx +15 -13
  64. package/src/components/gallery/components/loading-image.tsx +15 -12
  65. package/src/components/gallery/hooks/use-gallery-images.ts +51 -10
  66. package/src/components/gallery/index.tsx +32 -26
  67. package/src/components/header.tsx +14 -9
  68. package/src/components/mdx-components.tsx +61 -8
  69. package/src/components/post-list.tsx +149 -115
  70. package/src/components/running-bar.tsx +1 -1
  71. package/src/components/slide.tsx +22 -5
  72. package/src/components/slides-renderer.tsx +7 -115
  73. package/src/components/welcome.tsx +11 -14
  74. package/src/hooks/use-mdx-content.ts +94 -9
  75. package/src/index.css +159 -0
  76. package/src/main.tsx +1 -0
  77. package/src/pages/content-router.tsx +27 -0
  78. package/src/pages/home.tsx +16 -2
  79. package/src/pages/post.tsx +10 -13
  80. package/src/pages/slides.tsx +75 -88
  81. package/src/vite-env.d.ts +7 -17
  82. package/vite.config.ts +25 -6
@@ -0,0 +1,100 @@
1
+ import type { Root, Content } from 'mdast'
2
+
3
+ /**
4
+ * Remark plugin that transforms MDX content into slides.
5
+ * Splits content at thematic breaks (---) and wraps each section
6
+ * in a <Slide index={n}> component.
7
+ *
8
+ * Also exports `slideCount` from the MDX module.
9
+ *
10
+ * Note: This plugin should run AFTER remark-frontmatter so that
11
+ * YAML frontmatter is already extracted and won't be confused with
12
+ * slide breaks.
13
+ */
14
+ export function remarkSlides() {
15
+ return (tree: Root) => {
16
+ const slides: Content[][] = [[]]
17
+ let frontmatterNode: Content | null = null
18
+
19
+ // Split children by thematic breaks (skip yaml/toml frontmatter nodes)
20
+ for (const node of tree.children) {
21
+ if (node.type === 'thematicBreak') {
22
+ // Start a new slide
23
+ slides.push([])
24
+ } else if (node.type === 'yaml' || node.type === 'toml') {
25
+ // Keep frontmatter to add back later
26
+ frontmatterNode = node
27
+ } else {
28
+ // Add to current slide
29
+ slides[slides.length - 1].push(node)
30
+ }
31
+ }
32
+
33
+ // Filter out empty slides
34
+ const nonEmptySlides = slides.filter(slide => slide.length > 0)
35
+
36
+ // Build new tree with Slide wrappers
37
+ const newChildren: Content[] = []
38
+
39
+ // Preserve frontmatter at the top (for remark-mdx-frontmatter to process)
40
+ if (frontmatterNode) {
41
+ newChildren.push(frontmatterNode)
42
+ }
43
+
44
+ // Add slideCount export
45
+ newChildren.push({
46
+ type: 'mdxjsEsm',
47
+ value: `export const slideCount = ${nonEmptySlides.length};`,
48
+ data: {
49
+ estree: {
50
+ type: 'Program',
51
+ sourceType: 'module',
52
+ body: [{
53
+ type: 'ExportNamedDeclaration',
54
+ declaration: {
55
+ type: 'VariableDeclaration',
56
+ kind: 'const',
57
+ declarations: [{
58
+ type: 'VariableDeclarator',
59
+ id: { type: 'Identifier', name: 'slideCount' },
60
+ init: { type: 'Literal', value: nonEmptySlides.length }
61
+ }]
62
+ },
63
+ specifiers: [],
64
+ source: null
65
+ }]
66
+ }
67
+ }
68
+ } as any)
69
+
70
+ // Wrap each slide's content in a Slide component
71
+ nonEmptySlides.forEach((slideContent, index) => {
72
+ // Opening <Slide index={n}>
73
+ newChildren.push({
74
+ type: 'mdxJsxFlowElement',
75
+ name: 'Slide',
76
+ attributes: [{
77
+ type: 'mdxJsxAttribute',
78
+ name: 'index',
79
+ value: {
80
+ type: 'mdxJsxAttributeValueExpression',
81
+ value: String(index),
82
+ data: {
83
+ estree: {
84
+ type: 'Program',
85
+ sourceType: 'module',
86
+ body: [{
87
+ type: 'ExpressionStatement',
88
+ expression: { type: 'Literal', value: index }
89
+ }]
90
+ }
91
+ }
92
+ }
93
+ }],
94
+ children: slideContent
95
+ } as any)
96
+ })
97
+
98
+ tree.children = newChildren
99
+ }
100
+ }
@@ -0,0 +1,22 @@
1
+ export interface SiteConfig {
2
+ name?: string;
3
+ description?: string;
4
+ github?: string;
5
+ }
6
+
7
+ export interface VeslxConfig {
8
+ dir?: string;
9
+ site?: SiteConfig;
10
+ }
11
+
12
+ export interface ResolvedSiteConfig {
13
+ name: string;
14
+ description: string;
15
+ github: string;
16
+ }
17
+
18
+ export const DEFAULT_SITE_CONFIG: ResolvedSiteConfig = {
19
+ name: 'veslx',
20
+ description: '',
21
+ github: '',
22
+ };
package/src/App.tsx CHANGED
@@ -1,17 +1,14 @@
1
1
  import { BrowserRouter, Routes, Route } from "react-router-dom"
2
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"
3
+ import { ContentRouter } from "./pages/content-router"
6
4
 
7
5
  function App() {
8
6
  return (
9
7
  <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
10
8
  <BrowserRouter>
11
9
  <Routes>
12
- <Route path=":path/SLIDES.mdx" element={<SlidesPage />} />
13
- <Route path=":path/README.mdx" element={<Post />} />
14
- <Route path="/*" element={<Home />} />
10
+ {/* Single catch-all route - ContentRouter determines page type */}
11
+ <Route path="/*" element={<ContentRouter />} />
15
12
  </Routes>
16
13
  </BrowserRouter>
17
14
  </ThemeProvider>
@@ -1,49 +1,34 @@
1
+ import { useMDXContent, useMDXSlides } from "@/hooks/use-mdx-content";
1
2
  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"
3
+ import { useParams } from "react-router-dom"
5
4
 
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
- }){
5
+ export function FrontMatter(){
6
+ const { "path": path = "." } = useParams();
7
+ const { frontmatter: readmeFm } = useMDXContent(path);
8
+ const { frontmatter: slidesFm } = useMDXSlides(path);
9
+
10
+ let frontmatter = readmeFm || slidesFm;
17
11
 
18
12
  return (
19
13
  <div>
20
- {title && (
14
+ {frontmatter?.title && (
21
15
  <header className="not-prose flex flex-col gap-2 mb-8 pt-4">
22
16
  <h1 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
23
- {title}
17
+ {frontmatter?.title}
24
18
  </h1>
25
19
 
26
20
  {/* Meta line */}
27
21
  <div className="flex flex-wrap items-center gap-3 text-muted-foreground">
28
- {date && (
22
+ {frontmatter?.date && (
29
23
  <time className="font-mono text-xs bg-muted px-2 py-0.5 rounded">
30
- {formatDate(new Date(date as string))}
24
+ {formatDate(new Date(frontmatter.date as string))}
31
25
  </time>
32
26
  )}
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
27
  </div>
43
28
 
44
- {description && (
29
+ {frontmatter?.description && (
45
30
  <div className="flex flex-wrap text-sm items-center gap-3 text-muted-foreground">
46
- {description}
31
+ {frontmatter?.description}
47
32
  </div>
48
33
  )}
49
34
  </header>
@@ -4,12 +4,20 @@ export function FigureCaption({ caption, label }: { caption?: string; label?: st
4
4
  if (!caption && !label) return null;
5
5
 
6
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>
7
+ <figcaption className="px-[calc((var(--gallery-width)-var(--content-width))/2)] mt-4">
8
+ <p className="text-[13px] leading-[1.6] text-muted-foreground">
9
+ {label && (
10
+ <span className="font-semibold text-foreground tracking-tight">
11
+ {label}
12
+ {caption && <span className="font-normal mx-1.5">·</span>}
13
+ </span>
14
+ )}
15
+ {caption && (
16
+ <span className="text-muted-foreground/90">
17
+ {renderMathInText(caption)}
18
+ </span>
19
+ )}
20
+ </p>
21
+ </figcaption>
14
22
  );
15
23
  }
@@ -4,14 +4,14 @@ export function FigureHeader({ title, subtitle }: { title?: string; subtitle?: s
4
4
  if (!title && !subtitle) return null;
5
5
 
6
6
  return (
7
- <div className="mx-auto max-w-md">
7
+ <div className="px-[calc((var(--gallery-width)-var(--content-width))/2)] mb-4">
8
8
  {title && (
9
- <h3 className="text-sm md:text-base font-medium tracking-tight text-foreground text-left">
9
+ <h3 className="text-[15px] font-medium tracking-[-0.01em] text-foreground">
10
10
  {renderMathInText(title)}
11
11
  </h3>
12
12
  )}
13
13
  {subtitle && (
14
- <p className="text-sm text-muted-foreground leading-relaxed text-left mt-1">
14
+ <p className="text-[13px] text-muted-foreground/80 leading-relaxed mt-1">
15
15
  {renderMathInText(subtitle)}
16
16
  </p>
17
17
  )}
@@ -31,25 +31,27 @@ export function Lightbox({
31
31
 
32
32
  return createPortal(
33
33
  <div
34
- className="fixed inset-0 z-[9999] bg-background"
34
+ className="fixed inset-0 z-[9999] bg-background/98 backdrop-blur-md animate-fade-in-slow"
35
35
  onClick={onClose}
36
36
  {...{ [FULLSCREEN_DATA_ATTR]: "true" }}
37
37
  style={{ top: 0, left: 0, right: 0, bottom: 0 }}
38
38
  >
39
39
  {/* Top bar */}
40
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"
41
+ className="fixed top-0 left-0 right-0 z-10 flex items-center justify-between px-6 py-4"
42
42
  onClick={(e) => e.stopPropagation()}
43
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')}
44
+ <div className="font-mono text-[11px] text-muted-foreground/60 tabular-nums tracking-wider uppercase">
45
+ {String(selectedIndex + 1).padStart(2, '0')}
46
+ <span className="mx-1.5 text-muted-foreground/30">/</span>
47
+ {String(images.length).padStart(2, '0')}
46
48
  </div>
47
49
  <button
48
50
  onClick={onClose}
49
- className="p-2 text-muted-foreground hover:text-foreground transition-colors"
51
+ className="p-2 -m-2 text-muted-foreground/50 hover:text-foreground transition-colors duration-200"
50
52
  aria-label="Close"
51
53
  >
52
- <X className="h-5 w-5" />
54
+ <X className="h-4 w-4" strokeWidth={1.5} />
53
55
  </button>
54
56
  </div>
55
57
 
@@ -60,10 +62,10 @@ export function Lightbox({
60
62
  e.stopPropagation();
61
63
  onPrevious();
62
64
  }}
63
- className="fixed left-4 top-1/2 -translate-y-1/2 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
65
+ className="fixed left-6 top-1/2 -translate-y-1/2 z-10 p-3 -m-3 text-muted-foreground/40 hover:text-foreground transition-colors duration-200"
64
66
  aria-label="Previous image"
65
67
  >
66
- <ChevronLeft className="h-8 w-8" />
68
+ <ChevronLeft className="h-6 w-6" strokeWidth={1.5} />
67
69
  </button>
68
70
  )}
69
71
 
@@ -74,10 +76,10 @@ export function Lightbox({
74
76
  e.stopPropagation();
75
77
  onNext();
76
78
  }}
77
- className="fixed right-4 top-1/2 -translate-y-1/2 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
79
+ className="fixed right-6 top-1/2 -translate-y-1/2 z-10 p-3 -m-3 text-muted-foreground/40 hover:text-foreground transition-colors duration-200"
78
80
  aria-label="Next image"
79
81
  >
80
- <ChevronRight className="h-8 w-8" />
82
+ <ChevronRight className="h-6 w-6" strokeWidth={1.5} />
81
83
  </button>
82
84
  )}
83
85
 
@@ -86,17 +88,17 @@ export function Lightbox({
86
88
  <img
87
89
  src={current.src}
88
90
  alt={current.label}
89
- className="max-w-full max-h-full object-contain"
91
+ className="max-w-full max-h-full object-contain rounded-sm shadow-2xl"
90
92
  onClick={(e) => e.stopPropagation()}
91
93
  />
92
94
  </div>
93
95
 
94
96
  {/* Caption */}
95
97
  <div
96
- className="fixed bottom-0 left-0 right-0 z-10 p-4 text-center bg-background/80 backdrop-blur-sm"
98
+ className="fixed bottom-0 left-0 right-0 z-10 px-6 py-5 text-center"
97
99
  onClick={(e) => e.stopPropagation()}
98
100
  >
99
- <span className="font-mono text-xs text-muted-foreground">
101
+ <span className="font-mono text-[11px] text-muted-foreground/50 tracking-wide">
100
102
  {current.label}
101
103
  </span>
102
104
  </div>
@@ -1,5 +1,5 @@
1
1
  import { useState, ImgHTMLAttributes } from "react";
2
- import { Image } from "lucide-react";
2
+ import { ImageOff } from "lucide-react";
3
3
  import { cn } from "@/lib/utils";
4
4
 
5
5
  export function LoadingImage({
@@ -11,27 +11,30 @@ export function LoadingImage({
11
11
  const [hasError, setHasError] = useState(false);
12
12
 
13
13
  return (
14
- <div className={cn("relative", wrapperClassName)}>
14
+ <div className={cn("relative overflow-hidden rounded-sm bg-muted/20", wrapperClassName)}>
15
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" />
16
+ <div className="absolute inset-0 flex items-center justify-center">
17
+ <div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/40 to-transparent animate-shimmer" />
18
18
  </div>
19
19
  )}
20
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>
21
+ <div className="absolute inset-0 bg-muted/10 flex items-center justify-center backdrop-blur-sm">
22
+ <div className="text-center space-y-1">
23
+ <ImageOff className="h-4 w-4 text-muted-foreground/30 mx-auto" strokeWidth={1.5} />
24
+ <span className="text-[10px] text-muted-foreground/30 block font-mono uppercase tracking-wider">
25
+ unavailable
26
+ </span>
25
27
  </div>
26
28
  </div>
27
29
  )}
28
30
  <img
29
31
  {...props}
30
32
  className={cn(
31
- className,
32
- "transition-opacity duration-500 ease-out-expo",
33
- isLoading && "opacity-0",
34
- hasError && "opacity-0"
33
+ "w-full h-full",
34
+ "transition-all duration-500 ease-out",
35
+ isLoading ? "opacity-0 scale-[1.02]" : "opacity-100 scale-100",
36
+ hasError && "opacity-0",
37
+ className
35
38
  )}
36
39
  onLoad={(e) => {
37
40
  setIsLoading(false);
@@ -1,9 +1,26 @@
1
1
  import { useMemo } from "react";
2
2
  import { useTheme } from "next-themes";
3
+ import { useParams } from "react-router-dom";
3
4
  import { useDirectory } from "../../../../plugin/src/client";
4
- import { FileEntry } from "../../../../plugin/src/lib";
5
+ import { FileEntry, DirectoryEntry } from "../../../../plugin/src/lib";
5
6
  import { minimatch } from "minimatch";
6
7
 
8
+ // Recursively collect all image files from a directory tree
9
+ function collectAllImages(entry: DirectoryEntry | FileEntry): FileEntry[] {
10
+ if (entry.type === "file") {
11
+ if (entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i)) {
12
+ return [entry];
13
+ }
14
+ return [];
15
+ }
16
+ // It's a directory - recurse into children
17
+ const images: FileEntry[] = [];
18
+ for (const child of entry.children || []) {
19
+ images.push(...collectAllImages(child));
20
+ }
21
+ return images;
22
+ }
23
+
7
24
  function sortPathsNumerically(paths: string[]): void {
8
25
  paths.sort((a, b) => {
9
26
  const nums = (s: string) => (s.match(/\d+/g) || []).map(Number);
@@ -65,24 +82,48 @@ export function useGalleryImages({
65
82
  page?: number;
66
83
  }) {
67
84
  const { resolvedTheme } = useTheme();
85
+ const { "*": routePath = "" } = useParams();
68
86
 
69
- let resolvedPath = path;
87
+ // Get the current post's directory from the route
88
+ // Route is like "04-components/README.mdx" -> "04-components"
89
+ // Or "gallery-examples" -> "gallery-examples"
90
+ const currentDir = routePath
91
+ .replace(/\/[^/]+\.mdx$/i, "") // Remove /filename.mdx
92
+ .replace(/\/$/, "") // Remove trailing slash
93
+ || ".";
70
94
 
71
- const { directory } = useDirectory(resolvedPath);
95
+ // Resolve the path relative to current directory
96
+ let resolvedPath = path;
97
+ if (path?.startsWith("./")) {
98
+ // Relative path like "./images" -> "gallery-examples/images"
99
+ const relativePart = path.slice(2);
100
+ resolvedPath = currentDir === "." ? relativePart : `${currentDir}/${relativePart}`;
101
+ } else if (path && !path.startsWith("/") && !path.includes("/")) {
102
+ // Simple name like "images" -> "gallery-examples/images"
103
+ resolvedPath = currentDir === "." ? path : `${currentDir}/${path}`;
104
+ }
105
+
106
+ // If only globs provided (no path), use root directory
107
+ const directoryPath = resolvedPath || ".";
108
+ const { directory } = useDirectory(directoryPath);
72
109
 
73
110
  const paths = useMemo(() => {
74
111
  if (!directory) return [];
75
112
 
76
- const imageChildren = directory.children.filter((child): child is FileEntry => {
77
- return !!child.name.match(/\.(png|jpeg|gif|svg|webp)$/i) && child.type === "file";
78
- });
79
-
80
- let imagePaths = imageChildren.map(child => child.path);
113
+ let imagePaths: string[];
81
114
 
82
115
  if (globs && globs.length > 0) {
83
- imagePaths = imagePaths.filter(p => {
84
- return globs.some(glob => minimatch(p.split('/').pop() || '', glob));
116
+ // When globs provided, collect all images recursively and match against filename
117
+ const allImages = collectAllImages(directory);
118
+ imagePaths = allImages
119
+ .map(img => img.path)
120
+ .filter(p => globs.some(glob => minimatch(p, glob, { matchBase: true })));
121
+ } else {
122
+ // No globs - just get images from the specified directory
123
+ const imageChildren = directory.children.filter((child): child is FileEntry => {
124
+ return !!child.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i) && child.type === "file";
85
125
  });
126
+ imagePaths = imageChildren.map(child => child.path);
86
127
  }
87
128
 
88
129
  sortPathsNumerically(imagePaths);
@@ -62,63 +62,69 @@ export default function Gallery({
62
62
 
63
63
  if (isLoading) {
64
64
  return (
65
- <div className="not-prose py-4 md:py-6">
66
- <div className="grid grid-cols-3 gap-2 sm:gap-4 max-w-[var(--gallery-width)] mx-auto">
65
+ <figure className="not-prose py-6 md:py-8">
66
+ <div className="grid grid-cols-3 gap-3 max-w-[var(--gallery-width)] mx-auto">
67
67
  {[...Array(3)].map((_, i) => (
68
68
  <div
69
69
  key={i}
70
- className="aspect-[4/3] bg-muted/30 animate-pulse"
71
- style={{ animationDelay: `${i * 100}ms` }}
72
- />
70
+ className="aspect-square rounded-sm bg-muted/20 relative overflow-hidden"
71
+ >
72
+ <div
73
+ className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/30 to-transparent animate-shimmer"
74
+ style={{ animationDelay: `${i * 150}ms` }}
75
+ />
76
+ </div>
73
77
  ))}
74
78
  </div>
75
- </div>
79
+ </figure>
76
80
  );
77
81
  }
78
82
 
79
83
  if (isEmpty) {
80
84
  return (
81
- <div className="not-prose py-16 text-center">
82
- <div className="inline-flex items-center gap-3 text-muted-foreground/60">
83
- <Image className="h-4 w-4" />
84
- <span className="font-mono text-sm tracking-wide">no image(s) found</span>
85
+ <figure className="not-prose py-12 text-center">
86
+ <div className="inline-flex items-center gap-2.5 text-muted-foreground/40">
87
+ <Image className="h-3.5 w-3.5" strokeWidth={1.5} />
88
+ <span className="font-mono text-xs uppercase tracking-widest">No images</span>
85
89
  </div>
86
- </div>
90
+ </figure>
87
91
  );
88
92
  }
89
93
 
90
94
  return (
91
95
  <>
92
- <div className="rounded-lg not-prose flex flex-col gap-0 relative p-4 -mx-[calc((var(--gallery-width)-var(--content-width))/2+var(--page-padding))]">
96
+ <figure className="not-prose relative py-6 md:py-8 -mx-[calc((var(--gallery-width)-var(--content-width))/2+var(--page-padding))]">
93
97
  <FigureHeader title={title} subtitle={subtitle} />
94
98
 
95
- <Carousel>
96
- <CarouselContent>
99
+ <Carousel className="w-full">
100
+ <CarouselContent className={`-ml-2 md:-ml-3 ${images.length < 3 ? 'justify-center' : ''}`}>
97
101
  {images.map((img, index) => (
98
- <CarouselItem
99
- key={index}
100
- className="mx-auto md:basis-1/2 lg:basis-1/3 cursor-pointer transition-transform duration-700 ease-out-expo hover:scale-[1.02]"
101
- // wrapperClassName="h-full"
102
+ <CarouselItem
103
+ key={index}
104
+ className={`pl-2 md:pl-3 md:basis-1/2 lg:basis-1/3 cursor-pointer group ${images.length < 3 ? 'flex-none' : ''}`}
102
105
  onClick={() => lightbox.open(index)}
103
106
  >
104
- <LoadingImage
105
- src={img.src}
106
- alt={img.label}
107
- />
107
+ <div className="aspect-square overflow-hidden rounded-sm ring-1 ring-border/50 transition-all duration-300 group-hover:ring-border group-hover:shadow-lg bg-muted/10">
108
+ <LoadingImage
109
+ src={img.src}
110
+ alt={img.label}
111
+ className="object-contain transition-transform duration-500 ease-out group-hover:scale-[1.02]"
112
+ />
113
+ </div>
108
114
  </CarouselItem>
109
115
  ))}
110
116
  </CarouselContent>
111
117
 
112
118
  {images.length > 3 && (
113
119
  <>
114
- <CarouselPrevious />
115
- <CarouselNext />
120
+ <CarouselPrevious className="left-4 bg-background/80 backdrop-blur-sm border-border/50 hover:bg-background hover:border-border" />
121
+ <CarouselNext className="right-4 bg-background/80 backdrop-blur-sm border-border/50 hover:bg-background hover:border-border" />
116
122
  </>
117
- )}
123
+ )}
118
124
  </Carousel>
119
125
 
120
126
  <FigureCaption caption={caption} label={captionLabel} />
121
- </div>
127
+ </figure>
122
128
 
123
129
  {lightbox.isOpen && lightbox.selectedIndex !== null && (
124
130
  <Lightbox
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
2
2
  import { ModeToggle } from "./mode-toggle";
3
3
  import { SiGithub } from "@icons-pack/react-simple-icons";
4
4
  import { ChevronUp, ChevronDown } from "lucide-react";
5
+ import siteConfig from "virtual:veslx-config";
5
6
 
6
7
  interface HeaderProps {
7
8
  slideControls?: {
@@ -13,6 +14,8 @@ interface HeaderProps {
13
14
  }
14
15
 
15
16
  export function Header({ slideControls }: HeaderProps = {}) {
17
+ const config = siteConfig;
18
+
16
19
  return (
17
20
  <header className="print:hidden fixed top-0 left-0 right-0 z-40">
18
21
  <div className="mx-auto w-full px-[var(--page-padding)] flex items-center gap-8 py-4">
@@ -21,7 +24,7 @@ export function Header({ slideControls }: HeaderProps = {}) {
21
24
  to="/"
22
25
  className="rounded-lg font-mono py-1.5 text-sm font-medium text-muted-foreground hover:underline"
23
26
  >
24
- pl
27
+ {config.name}
25
28
  </Link>
26
29
  </nav>
27
30
 
@@ -52,14 +55,16 @@ export function Header({ slideControls }: HeaderProps = {}) {
52
55
 
53
56
  {/* Navigation */}
54
57
  <nav className="flex items-center gap-2">
55
- <Link
56
- to="https://github.com/eoinmurray/pinglab"
57
- target="_blank"
58
- className="text-muted-foreground/70 hover:text-foreground transition-colors duration-300"
59
- aria-label="GitHub"
60
- >
61
- <SiGithub className="h-4 w-4" />
62
- </Link>
58
+ {config.github && (
59
+ <Link
60
+ to={`https://github.com/${config.github}`}
61
+ target="_blank"
62
+ className="text-muted-foreground/70 hover:text-foreground transition-colors duration-300"
63
+ aria-label="GitHub"
64
+ >
65
+ <SiGithub className="h-4 w-4" />
66
+ </Link>
67
+ )}
63
68
  <ModeToggle />
64
69
  </nav>
65
70
  </div>