blumenjs 0.2.5 → 0.2.6
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/dist/cli/blumen.js +21 -0
- package/dist/cli/commands/create.js +21 -0
- package/dist/templates/app/shared/BlumenHead.tsx +157 -0
- package/dist/templates/app/shared/BlumenImage.tsx +209 -0
- package/dist/templates/app/shared/DefaultLoading.tsx +78 -0
- package/dist/templates/app/shared/ErrorBoundary.tsx +174 -0
- package/dist/templates/app/shared/api.ts +99 -0
- package/dist/templates/app/shared/blumenConfig.ts +179 -0
- package/dist/templates/app/shared/i18n.ts +281 -0
- package/dist/templates/app/shared/prefetchCache.ts +142 -0
- package/dist/templates/app/shared/serverAction.ts +84 -0
- package/dist/templates/app/shared/types.ts +132 -0
- package/dist/templates/app/shared/useServerAction.ts +173 -0
- package/dist/templates/app/shared/useWebSocket.ts +237 -0
- package/dist/templates/go.mod +8 -0
- package/dist/templates/go.sum +4 -0
- package/package.json +1 -1
package/dist/cli/blumen.js
CHANGED
|
@@ -838,11 +838,32 @@ function getTemplateFiles(projectName, template) {
|
|
|
838
838
|
// Complex files — copied from the framework source
|
|
839
839
|
["app/shared/RouterContext.tsx", readProjectFile("app/shared/RouterContext.tsx")],
|
|
840
840
|
["app/shared/router.ts", readProjectFile("app/shared/router.ts")],
|
|
841
|
+
["app/shared/ErrorBoundary.tsx", readProjectFile("app/shared/ErrorBoundary.tsx")],
|
|
842
|
+
["app/shared/BlumenHead.tsx", readProjectFile("app/shared/BlumenHead.tsx")],
|
|
843
|
+
["app/shared/prefetchCache.ts", readProjectFile("app/shared/prefetchCache.ts")],
|
|
844
|
+
["app/shared/DefaultLoading.tsx", readProjectFile("app/shared/DefaultLoading.tsx")],
|
|
845
|
+
["app/shared/serverAction.ts", readProjectFile("app/shared/serverAction.ts")],
|
|
846
|
+
["app/shared/types.ts", readProjectFile("app/shared/types.ts")],
|
|
847
|
+
["app/shared/useServerAction.ts", readProjectFile("app/shared/useServerAction.ts")],
|
|
848
|
+
["app/shared/api.ts", readProjectFile("app/shared/api.ts")],
|
|
849
|
+
["app/shared/useWebSocket.ts", readProjectFile("app/shared/useWebSocket.ts")],
|
|
850
|
+
["app/shared/i18n.ts", readProjectFile("app/shared/i18n.ts")],
|
|
851
|
+
["app/shared/blumenConfig.ts", readProjectFile("app/shared/blumenConfig.ts")],
|
|
852
|
+
["app/shared/BlumenImage.tsx", readProjectFile("app/shared/BlumenImage.tsx")],
|
|
841
853
|
["app/client/entry.tsx", readProjectFile("app/client/entry.tsx")],
|
|
842
854
|
["node-ssr/server.ts", readProjectFile("node-ssr/server.ts")],
|
|
843
855
|
["go-server/main.go", readProjectFile("go-server/main.go")],
|
|
856
|
+
["go-server/image.go", readProjectFile("go-server/image.go")],
|
|
857
|
+
["go-server/cache.go", readProjectFile("go-server/cache.go")],
|
|
858
|
+
["go-server/websocket.go", readProjectFile("go-server/websocket.go")],
|
|
859
|
+
["go-server/middleware.go", readProjectFile("go-server/middleware.go")],
|
|
860
|
+
["go-server/ssg.go", readProjectFile("go-server/ssg.go")],
|
|
861
|
+
["go-server/redirects.go", readProjectFile("go-server/redirects.go")],
|
|
862
|
+
["go-server/actions.go", readProjectFile("go-server/actions.go")],
|
|
844
863
|
["scripts/generate-routes.ts", readProjectFile("scripts/generate-routes.ts")],
|
|
845
864
|
["scripts/generate-api-routes.ts", readProjectFile("scripts/generate-api-routes.ts")],
|
|
865
|
+
["go.mod", readProjectFile("go.mod")],
|
|
866
|
+
["go.sum", readProjectFile("go.sum")],
|
|
846
867
|
// Placeholder
|
|
847
868
|
["static/js/.gitkeep", ""],
|
|
848
869
|
// Docker support (production deployment)
|
|
@@ -522,11 +522,32 @@ function getTemplateFiles(projectName, template) {
|
|
|
522
522
|
// Complex files — copied from the framework source
|
|
523
523
|
["app/shared/RouterContext.tsx", readProjectFile("app/shared/RouterContext.tsx")],
|
|
524
524
|
["app/shared/router.ts", readProjectFile("app/shared/router.ts")],
|
|
525
|
+
["app/shared/ErrorBoundary.tsx", readProjectFile("app/shared/ErrorBoundary.tsx")],
|
|
526
|
+
["app/shared/BlumenHead.tsx", readProjectFile("app/shared/BlumenHead.tsx")],
|
|
527
|
+
["app/shared/prefetchCache.ts", readProjectFile("app/shared/prefetchCache.ts")],
|
|
528
|
+
["app/shared/DefaultLoading.tsx", readProjectFile("app/shared/DefaultLoading.tsx")],
|
|
529
|
+
["app/shared/serverAction.ts", readProjectFile("app/shared/serverAction.ts")],
|
|
530
|
+
["app/shared/types.ts", readProjectFile("app/shared/types.ts")],
|
|
531
|
+
["app/shared/useServerAction.ts", readProjectFile("app/shared/useServerAction.ts")],
|
|
532
|
+
["app/shared/api.ts", readProjectFile("app/shared/api.ts")],
|
|
533
|
+
["app/shared/useWebSocket.ts", readProjectFile("app/shared/useWebSocket.ts")],
|
|
534
|
+
["app/shared/i18n.ts", readProjectFile("app/shared/i18n.ts")],
|
|
535
|
+
["app/shared/blumenConfig.ts", readProjectFile("app/shared/blumenConfig.ts")],
|
|
536
|
+
["app/shared/BlumenImage.tsx", readProjectFile("app/shared/BlumenImage.tsx")],
|
|
525
537
|
["app/client/entry.tsx", readProjectFile("app/client/entry.tsx")],
|
|
526
538
|
["node-ssr/server.ts", readProjectFile("node-ssr/server.ts")],
|
|
527
539
|
["go-server/main.go", readProjectFile("go-server/main.go")],
|
|
540
|
+
["go-server/image.go", readProjectFile("go-server/image.go")],
|
|
541
|
+
["go-server/cache.go", readProjectFile("go-server/cache.go")],
|
|
542
|
+
["go-server/websocket.go", readProjectFile("go-server/websocket.go")],
|
|
543
|
+
["go-server/middleware.go", readProjectFile("go-server/middleware.go")],
|
|
544
|
+
["go-server/ssg.go", readProjectFile("go-server/ssg.go")],
|
|
545
|
+
["go-server/redirects.go", readProjectFile("go-server/redirects.go")],
|
|
546
|
+
["go-server/actions.go", readProjectFile("go-server/actions.go")],
|
|
528
547
|
["scripts/generate-routes.ts", readProjectFile("scripts/generate-routes.ts")],
|
|
529
548
|
["scripts/generate-api-routes.ts", readProjectFile("scripts/generate-api-routes.ts")],
|
|
549
|
+
["go.mod", readProjectFile("go.mod")],
|
|
550
|
+
["go.sum", readProjectFile("go.sum")],
|
|
530
551
|
// Placeholder
|
|
531
552
|
["static/js/.gitkeep", ""],
|
|
532
553
|
// Docker support (production deployment)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlumenHead — Client-side <head> tag manager.
|
|
3
|
+
*
|
|
4
|
+
* Updates document.title and manages <meta> tags on the client.
|
|
5
|
+
* Used internally by the router for SPA navigations, and available
|
|
6
|
+
* to developers as an escape hatch for imperative head control.
|
|
7
|
+
*
|
|
8
|
+
* On the server, this component renders nothing — the Document
|
|
9
|
+
* component handles <head> rendering during SSR.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* import { BlumenHead } from '../shared/BlumenHead';
|
|
14
|
+
*
|
|
15
|
+
* export default function MyPage() {
|
|
16
|
+
* return (
|
|
17
|
+
* <>
|
|
18
|
+
* <BlumenHead
|
|
19
|
+
* title="My Page | My App"
|
|
20
|
+
* description="A great page"
|
|
21
|
+
* />
|
|
22
|
+
* <div>Page content</div>
|
|
23
|
+
* </>
|
|
24
|
+
* );
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import React, { useEffect } from "react";
|
|
30
|
+
import type { BlumenMetadata } from "./types";
|
|
31
|
+
|
|
32
|
+
// Marker attribute to identify tags managed by BlumenHead
|
|
33
|
+
const BLUMEN_HEAD_ATTR = "data-blumen-head";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Apply metadata to the document <head>.
|
|
37
|
+
* Can be called from React effects or from the router.
|
|
38
|
+
*/
|
|
39
|
+
export function applyMetadata(metadata: BlumenMetadata): void {
|
|
40
|
+
if (typeof document === "undefined") return;
|
|
41
|
+
|
|
42
|
+
// Update title
|
|
43
|
+
if (metadata.title) {
|
|
44
|
+
document.title = metadata.title;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Remove all previously managed tags
|
|
48
|
+
const existing = document.querySelectorAll(`[${BLUMEN_HEAD_ATTR}]`);
|
|
49
|
+
existing.forEach((el) => el.remove());
|
|
50
|
+
|
|
51
|
+
const head = document.head;
|
|
52
|
+
|
|
53
|
+
// Helper to add a meta tag
|
|
54
|
+
const addMeta = (name: string, content: string) => {
|
|
55
|
+
const tag = document.createElement("meta");
|
|
56
|
+
tag.setAttribute("name", name);
|
|
57
|
+
tag.setAttribute("content", content);
|
|
58
|
+
tag.setAttribute(BLUMEN_HEAD_ATTR, "true");
|
|
59
|
+
head.appendChild(tag);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Helper to add an OG/Twitter meta tag (uses property instead of name)
|
|
63
|
+
const addProperty = (property: string, content: string) => {
|
|
64
|
+
const tag = document.createElement("meta");
|
|
65
|
+
tag.setAttribute("property", property);
|
|
66
|
+
tag.setAttribute("content", content);
|
|
67
|
+
tag.setAttribute(BLUMEN_HEAD_ATTR, "true");
|
|
68
|
+
head.appendChild(tag);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Description
|
|
72
|
+
if (metadata.description) {
|
|
73
|
+
addMeta("description", metadata.description);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Keywords
|
|
77
|
+
if (metadata.keywords?.length) {
|
|
78
|
+
addMeta("keywords", metadata.keywords.join(", "));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Robots
|
|
82
|
+
if (metadata.robots) {
|
|
83
|
+
addMeta("robots", metadata.robots);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Canonical
|
|
87
|
+
if (metadata.canonical) {
|
|
88
|
+
const link = document.createElement("link");
|
|
89
|
+
link.setAttribute("rel", "canonical");
|
|
90
|
+
link.setAttribute("href", metadata.canonical);
|
|
91
|
+
link.setAttribute(BLUMEN_HEAD_ATTR, "true");
|
|
92
|
+
head.appendChild(link);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Open Graph
|
|
96
|
+
if (metadata.openGraph) {
|
|
97
|
+
const og = metadata.openGraph;
|
|
98
|
+
if (og.title) addProperty("og:title", og.title);
|
|
99
|
+
if (og.description) addProperty("og:description", og.description);
|
|
100
|
+
if (og.image) addProperty("og:image", og.image);
|
|
101
|
+
if (og.url) addProperty("og:url", og.url);
|
|
102
|
+
if (og.type) addProperty("og:type", og.type);
|
|
103
|
+
if (og.siteName) addProperty("og:site_name", og.siteName);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Twitter Card
|
|
107
|
+
if (metadata.twitter) {
|
|
108
|
+
const tw = metadata.twitter;
|
|
109
|
+
if (tw.card) addMeta("twitter:card", tw.card);
|
|
110
|
+
if (tw.title) addMeta("twitter:title", tw.title);
|
|
111
|
+
if (tw.description) addMeta("twitter:description", tw.description);
|
|
112
|
+
if (tw.image) addMeta("twitter:image", tw.image);
|
|
113
|
+
if (tw.creator) addMeta("twitter:creator", tw.creator);
|
|
114
|
+
if (tw.site) addMeta("twitter:site", tw.site);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Custom meta tags
|
|
118
|
+
if (metadata.other) {
|
|
119
|
+
for (const [name, content] of Object.entries(metadata.other)) {
|
|
120
|
+
addMeta(name, content);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* BlumenHead component — declarative head management.
|
|
127
|
+
*
|
|
128
|
+
* Accepts the same shape as BlumenMetadata. Updates <head> on mount
|
|
129
|
+
* and cleans up on unmount. SSR-safe (renders nothing on the server).
|
|
130
|
+
*/
|
|
131
|
+
export function BlumenHead(props: BlumenMetadata) {
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
applyMetadata(props);
|
|
134
|
+
|
|
135
|
+
// Cleanup: remove managed tags when component unmounts
|
|
136
|
+
return () => {
|
|
137
|
+
if (typeof document === "undefined") return;
|
|
138
|
+
const managed = document.querySelectorAll(`[${BLUMEN_HEAD_ATTR}]`);
|
|
139
|
+
managed.forEach((el) => el.remove());
|
|
140
|
+
};
|
|
141
|
+
}, [
|
|
142
|
+
props.title,
|
|
143
|
+
props.description,
|
|
144
|
+
props.canonical,
|
|
145
|
+
props.robots,
|
|
146
|
+
props.keywords?.join(","),
|
|
147
|
+
props.openGraph?.title,
|
|
148
|
+
props.openGraph?.description,
|
|
149
|
+
props.openGraph?.image,
|
|
150
|
+
props.twitter?.card,
|
|
151
|
+
props.twitter?.title,
|
|
152
|
+
props.twitter?.image,
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
// Render nothing — head mutations are side effects
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BlumenImage - Optimized image component for BlumenJS.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Automatic responsive srcset generation
|
|
8
|
+
* - Lazy loading by default (disable with `priority`)
|
|
9
|
+
* - WebP/AVIF format negotiation via the Go image endpoint
|
|
10
|
+
* - Blur placeholder (LQIP) fade-in effect
|
|
11
|
+
* - On-the-fly server-side resizing and caching
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { BlumenImage } from '../../shared/BlumenImage';
|
|
16
|
+
*
|
|
17
|
+
* <BlumenImage
|
|
18
|
+
* src="/static/hero.jpg"
|
|
19
|
+
* alt="Hero banner"
|
|
20
|
+
* width={1200}
|
|
21
|
+
* height={600}
|
|
22
|
+
* quality={80}
|
|
23
|
+
* priority // Above-the-fold: skip lazy loading
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Default responsive breakpoints (matches common device widths)
|
|
29
|
+
const DEFAULT_WIDTHS = [640, 750, 828, 1080, 1200, 1920, 2048];
|
|
30
|
+
|
|
31
|
+
interface BlumenImageProps {
|
|
32
|
+
/** Source path relative to the project root (e.g. "/static/hero.jpg") */
|
|
33
|
+
src: string;
|
|
34
|
+
/** Alt text for accessibility */
|
|
35
|
+
alt: string;
|
|
36
|
+
/** Display width in pixels */
|
|
37
|
+
width: number;
|
|
38
|
+
/** Display height in pixels */
|
|
39
|
+
height: number;
|
|
40
|
+
/** Image quality (1-100, default: 80) */
|
|
41
|
+
quality?: number;
|
|
42
|
+
/** If true, disables lazy loading and loads immediately (use for above-the-fold images) */
|
|
43
|
+
priority?: boolean;
|
|
44
|
+
/** Object-fit CSS property (default: "cover") */
|
|
45
|
+
fit?: "cover" | "contain" | "fill" | "none";
|
|
46
|
+
/** Additional CSS class name */
|
|
47
|
+
className?: string;
|
|
48
|
+
/** Custom responsive widths for srcset generation */
|
|
49
|
+
sizes?: string;
|
|
50
|
+
/** Inline style overrides */
|
|
51
|
+
style?: React.CSSProperties;
|
|
52
|
+
/** Border radius */
|
|
53
|
+
borderRadius?: string | number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the optimized image URL using the Go /_blumen/image endpoint.
|
|
58
|
+
*/
|
|
59
|
+
function buildImageUrl(
|
|
60
|
+
src: string,
|
|
61
|
+
width: number,
|
|
62
|
+
quality: number,
|
|
63
|
+
): string {
|
|
64
|
+
// If src is already an external URL, return as-is
|
|
65
|
+
if (src.startsWith("http://") || src.startsWith("https://")) {
|
|
66
|
+
return src;
|
|
67
|
+
}
|
|
68
|
+
return `/_blumen/image?src=${encodeURIComponent(src)}&w=${width}&q=${quality}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate a tiny blur data URI for the placeholder.
|
|
73
|
+
* Uses the Go endpoint with a very small width.
|
|
74
|
+
*/
|
|
75
|
+
function buildBlurUrl(src: string): string {
|
|
76
|
+
if (src.startsWith("http://") || src.startsWith("https://")) {
|
|
77
|
+
return src;
|
|
78
|
+
}
|
|
79
|
+
return `/_blumen/image?src=${encodeURIComponent(src)}&w=20&q=30&blur=1`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function BlumenImage({
|
|
83
|
+
src,
|
|
84
|
+
alt,
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
quality = 80,
|
|
88
|
+
priority = false,
|
|
89
|
+
fit = "cover",
|
|
90
|
+
className = "",
|
|
91
|
+
sizes,
|
|
92
|
+
style,
|
|
93
|
+
borderRadius,
|
|
94
|
+
}: BlumenImageProps) {
|
|
95
|
+
const [loaded, setLoaded] = useState(false);
|
|
96
|
+
const [error, setError] = useState(false);
|
|
97
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
98
|
+
|
|
99
|
+
// Generate the primary optimized URL
|
|
100
|
+
const optimizedSrc = buildImageUrl(src, width, quality);
|
|
101
|
+
|
|
102
|
+
// Generate srcset for responsive images
|
|
103
|
+
const srcsetWidths = DEFAULT_WIDTHS.filter((w) => w <= width * 2);
|
|
104
|
+
if (!srcsetWidths.includes(width)) {
|
|
105
|
+
srcsetWidths.push(width);
|
|
106
|
+
srcsetWidths.sort((a, b) => a - b);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const srcset = srcsetWidths
|
|
110
|
+
.map((w) => `${buildImageUrl(src, w, quality)} ${w}w`)
|
|
111
|
+
.join(", ");
|
|
112
|
+
|
|
113
|
+
// Default sizes attribute if not provided
|
|
114
|
+
const defaultSizes =
|
|
115
|
+
sizes || `(max-width: ${width}px) 100vw, ${width}px`;
|
|
116
|
+
|
|
117
|
+
// Blur placeholder URL
|
|
118
|
+
const blurSrc = buildBlurUrl(src);
|
|
119
|
+
|
|
120
|
+
// Check if image is already loaded (SSR or cached)
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (imgRef.current?.complete) {
|
|
123
|
+
setLoaded(true);
|
|
124
|
+
}
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const containerStyle: React.CSSProperties = {
|
|
128
|
+
position: "relative",
|
|
129
|
+
width: `${width}px`,
|
|
130
|
+
maxWidth: "100%",
|
|
131
|
+
aspectRatio: `${width} / ${height}`,
|
|
132
|
+
overflow: "hidden",
|
|
133
|
+
borderRadius: borderRadius || undefined,
|
|
134
|
+
...style,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const imgStyle: React.CSSProperties = {
|
|
138
|
+
display: "block",
|
|
139
|
+
width: "100%",
|
|
140
|
+
height: "100%",
|
|
141
|
+
objectFit: fit,
|
|
142
|
+
opacity: loaded ? 1 : 0,
|
|
143
|
+
transition: "opacity 0.4s ease-in-out",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const placeholderStyle: React.CSSProperties = {
|
|
147
|
+
position: "absolute",
|
|
148
|
+
inset: 0,
|
|
149
|
+
width: "100%",
|
|
150
|
+
height: "100%",
|
|
151
|
+
objectFit: fit,
|
|
152
|
+
filter: "blur(20px)",
|
|
153
|
+
transform: "scale(1.1)",
|
|
154
|
+
opacity: loaded ? 0 : 1,
|
|
155
|
+
transition: "opacity 0.4s ease-in-out",
|
|
156
|
+
pointerEvents: "none",
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (error) {
|
|
160
|
+
// Fallback: render a standard <img> without optimization
|
|
161
|
+
return (
|
|
162
|
+
<img
|
|
163
|
+
src={src}
|
|
164
|
+
alt={alt}
|
|
165
|
+
width={width}
|
|
166
|
+
height={height}
|
|
167
|
+
className={className}
|
|
168
|
+
style={{ objectFit: fit, maxWidth: "100%", ...style }}
|
|
169
|
+
loading={priority ? "eager" : "lazy"}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
className={`blumen-image ${className}`}
|
|
177
|
+
style={containerStyle}
|
|
178
|
+
>
|
|
179
|
+
{/* Blur placeholder */}
|
|
180
|
+
{!priority && (
|
|
181
|
+
<img
|
|
182
|
+
src={blurSrc}
|
|
183
|
+
alt=""
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
style={placeholderStyle}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{/* Main optimized image */}
|
|
190
|
+
<img
|
|
191
|
+
ref={imgRef}
|
|
192
|
+
src={optimizedSrc}
|
|
193
|
+
srcSet={srcset}
|
|
194
|
+
sizes={defaultSizes}
|
|
195
|
+
alt={alt}
|
|
196
|
+
width={width}
|
|
197
|
+
height={height}
|
|
198
|
+
loading={priority ? "eager" : "lazy"}
|
|
199
|
+
decoding={priority ? "sync" : "async"}
|
|
200
|
+
fetchPriority={priority ? "high" : "auto"}
|
|
201
|
+
onLoad={() => setLoaded(true)}
|
|
202
|
+
onError={() => setError(true)}
|
|
203
|
+
style={imgStyle}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export default BlumenImage;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blumen Default Loading Component
|
|
3
|
+
*
|
|
4
|
+
* A beautiful shimmer/skeleton loading state shown automatically
|
|
5
|
+
* during SPA navigation when the target page has getServerProps
|
|
6
|
+
* and a loading.tsx file.
|
|
7
|
+
*
|
|
8
|
+
* Developers can create their own loading.tsx files per directory
|
|
9
|
+
* for custom loading states, or use this default.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
|
|
14
|
+
const shimmerStyle: React.CSSProperties = {
|
|
15
|
+
background: "linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0) 100%)",
|
|
16
|
+
backgroundSize: "200% 100%",
|
|
17
|
+
animation: "blumen-shimmer 1.5s ease-in-out infinite",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const skeletonLine = (width: string, height: string = "14px", mb: string = "12px"): React.CSSProperties => ({
|
|
21
|
+
width,
|
|
22
|
+
height,
|
|
23
|
+
borderRadius: "6px",
|
|
24
|
+
backgroundColor: "rgba(255,255,255,0.06)",
|
|
25
|
+
marginBottom: mb,
|
|
26
|
+
position: "relative" as const,
|
|
27
|
+
overflow: "hidden" as const,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export default function DefaultLoading() {
|
|
31
|
+
return (
|
|
32
|
+
<div style={{ padding: "40px 24px", maxWidth: "680px", margin: "0 auto" }}>
|
|
33
|
+
<style>{`
|
|
34
|
+
@keyframes blumen-shimmer {
|
|
35
|
+
0% { background-position: 200% 0; }
|
|
36
|
+
100% { background-position: -200% 0; }
|
|
37
|
+
}
|
|
38
|
+
`}</style>
|
|
39
|
+
{/* Title skeleton */}
|
|
40
|
+
<div style={skeletonLine("60%", "28px", "24px")}>
|
|
41
|
+
<div style={{ ...shimmerStyle, position: "absolute", inset: 0 }} />
|
|
42
|
+
</div>
|
|
43
|
+
{/* Subtitle skeleton */}
|
|
44
|
+
<div style={skeletonLine("80%", "16px", "32px")}>
|
|
45
|
+
<div style={{ ...shimmerStyle, position: "absolute", inset: 0 }} />
|
|
46
|
+
</div>
|
|
47
|
+
{/* Content skeletons */}
|
|
48
|
+
<div style={skeletonLine("100%")}>
|
|
49
|
+
<div style={{ ...shimmerStyle, position: "absolute", inset: 0 }} />
|
|
50
|
+
</div>
|
|
51
|
+
<div style={skeletonLine("92%")}>
|
|
52
|
+
<div style={{ ...shimmerStyle, position: "absolute", inset: 0 }} />
|
|
53
|
+
</div>
|
|
54
|
+
<div style={skeletonLine("85%")}>
|
|
55
|
+
<div style={{ ...shimmerStyle, position: "absolute", inset: 0 }} />
|
|
56
|
+
</div>
|
|
57
|
+
<div style={skeletonLine("40%", "14px", "32px")}>
|
|
58
|
+
<div style={{ ...shimmerStyle, position: "absolute", inset: 0 }} />
|
|
59
|
+
</div>
|
|
60
|
+
{/* Card skeletons */}
|
|
61
|
+
<div style={{ display: "flex", gap: "16px", marginTop: "8px" }}>
|
|
62
|
+
{[1, 2, 3].map((i) => (
|
|
63
|
+
<div key={i} style={{
|
|
64
|
+
flex: 1,
|
|
65
|
+
height: "120px",
|
|
66
|
+
borderRadius: "12px",
|
|
67
|
+
backgroundColor: "rgba(255,255,255,0.04)",
|
|
68
|
+
border: "1px solid rgba(255,255,255,0.06)",
|
|
69
|
+
position: "relative",
|
|
70
|
+
overflow: "hidden",
|
|
71
|
+
}}>
|
|
72
|
+
<div style={{ ...shimmerStyle, position: "absolute", inset: 0 }} />
|
|
73
|
+
</div>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blumen Error Boundary
|
|
5
|
+
*
|
|
6
|
+
* Wraps the client hydration root to catch React rendering errors
|
|
7
|
+
* and display a user-friendly fallback instead of a white screen.
|
|
8
|
+
*
|
|
9
|
+
* In development, shows the error message and stack trace.
|
|
10
|
+
* In production, shows a clean recovery UI.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface ErrorBoundaryProps {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ErrorBoundaryState {
|
|
18
|
+
hasError: boolean;
|
|
19
|
+
error: Error | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class BlumenErrorBoundary extends React.Component<
|
|
23
|
+
ErrorBoundaryProps,
|
|
24
|
+
ErrorBoundaryState
|
|
25
|
+
> {
|
|
26
|
+
constructor(props: ErrorBoundaryProps) {
|
|
27
|
+
super(props);
|
|
28
|
+
this.state = { hasError: false, error: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
32
|
+
return { hasError: true, error };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
36
|
+
console.error("[Blumen] Client-side rendering error:", error);
|
|
37
|
+
console.error("[Blumen] Component stack:", errorInfo.componentStack);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
render() {
|
|
41
|
+
if (this.state.hasError) {
|
|
42
|
+
const isDev =
|
|
43
|
+
typeof window !== "undefined" &&
|
|
44
|
+
window.location.hostname === "localhost";
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
style={{
|
|
48
|
+
minHeight: "100vh",
|
|
49
|
+
display: "flex",
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
justifyContent: "center",
|
|
52
|
+
background: isDev ? "#0d1117" : "#f9fafb",
|
|
53
|
+
fontFamily:
|
|
54
|
+
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
|
55
|
+
padding: "2rem",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<div
|
|
59
|
+
style={{
|
|
60
|
+
maxWidth: 560,
|
|
61
|
+
width: "100%",
|
|
62
|
+
textAlign: isDev ? "left" : "center",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{isDev ? (
|
|
66
|
+
<>
|
|
67
|
+
<div
|
|
68
|
+
style={{
|
|
69
|
+
color: "#ff6b6b",
|
|
70
|
+
fontSize: "0.75rem",
|
|
71
|
+
fontWeight: 600,
|
|
72
|
+
textTransform: "uppercase",
|
|
73
|
+
letterSpacing: "0.05em",
|
|
74
|
+
marginBottom: 8,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
⚠ Client Rendering Error
|
|
78
|
+
</div>
|
|
79
|
+
<div
|
|
80
|
+
style={{
|
|
81
|
+
color: "#e2e8f0",
|
|
82
|
+
fontSize: "1.1rem",
|
|
83
|
+
fontWeight: 600,
|
|
84
|
+
marginBottom: 16,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{this.state.error?.message || "An unexpected error occurred"}
|
|
88
|
+
</div>
|
|
89
|
+
<pre
|
|
90
|
+
style={{
|
|
91
|
+
background: "#161b22",
|
|
92
|
+
border: "1px solid rgba(255,255,255,0.08)",
|
|
93
|
+
borderRadius: 8,
|
|
94
|
+
padding: 16,
|
|
95
|
+
fontSize: "0.75rem",
|
|
96
|
+
color: "#8b949e",
|
|
97
|
+
overflow: "auto",
|
|
98
|
+
maxHeight: 300,
|
|
99
|
+
lineHeight: 1.6,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{this.state.error?.stack}
|
|
103
|
+
</pre>
|
|
104
|
+
<button
|
|
105
|
+
onClick={() => window.location.reload()}
|
|
106
|
+
style={{
|
|
107
|
+
marginTop: 16,
|
|
108
|
+
padding: "8px 20px",
|
|
109
|
+
background: "#7c3aed",
|
|
110
|
+
color: "#fff",
|
|
111
|
+
border: "none",
|
|
112
|
+
borderRadius: 6,
|
|
113
|
+
fontSize: "0.85rem",
|
|
114
|
+
fontWeight: 600,
|
|
115
|
+
cursor: "pointer",
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
Reload Page
|
|
119
|
+
</button>
|
|
120
|
+
</>
|
|
121
|
+
) : (
|
|
122
|
+
<>
|
|
123
|
+
<div
|
|
124
|
+
style={{
|
|
125
|
+
fontSize: "3rem",
|
|
126
|
+
marginBottom: 16,
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
⚡
|
|
130
|
+
</div>
|
|
131
|
+
<h1
|
|
132
|
+
style={{
|
|
133
|
+
fontSize: "1.5rem",
|
|
134
|
+
fontWeight: 700,
|
|
135
|
+
color: "#1a202c",
|
|
136
|
+
marginBottom: 8,
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
Something went wrong
|
|
140
|
+
</h1>
|
|
141
|
+
<p
|
|
142
|
+
style={{
|
|
143
|
+
color: "#6b7280",
|
|
144
|
+
fontSize: "0.95rem",
|
|
145
|
+
marginBottom: 24,
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
An unexpected error occurred. Please try refreshing the page.
|
|
149
|
+
</p>
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => window.location.reload()}
|
|
152
|
+
style={{
|
|
153
|
+
padding: "10px 24px",
|
|
154
|
+
background: "#7c3aed",
|
|
155
|
+
color: "#fff",
|
|
156
|
+
border: "none",
|
|
157
|
+
borderRadius: 8,
|
|
158
|
+
fontSize: "0.9rem",
|
|
159
|
+
fontWeight: 600,
|
|
160
|
+
cursor: "pointer",
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
Refresh Page
|
|
164
|
+
</button>
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return this.props.children;
|
|
173
|
+
}
|
|
174
|
+
}
|