@morphika/webframe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +210 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Provider Abstraction Layer
|
|
3
|
+
*
|
|
4
|
+
* Defines a provider-agnostic interface for asset storage backends.
|
|
5
|
+
* Currently supports Cloudflare R2. The adapter pattern allows adding
|
|
6
|
+
* new providers in the future.
|
|
7
|
+
*
|
|
8
|
+
* The key insight: assets are stored as relative paths in Sanity.
|
|
9
|
+
* Only the resolution layer (path → public URL) needs to be pluggable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// Provider types
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
export type StorageProvider = "r2";
|
|
17
|
+
|
|
18
|
+
export interface StorageProviderConfig {
|
|
19
|
+
provider: StorageProvider;
|
|
20
|
+
connected: boolean;
|
|
21
|
+
connectedAt?: string;
|
|
22
|
+
/** Human-readable label, e.g. "R2 — my-assets" */
|
|
23
|
+
displayName: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================
|
|
27
|
+
// Scanned file — provider-agnostic
|
|
28
|
+
// ============================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A file discovered during a storage scan.
|
|
32
|
+
* Standardized field names (file_size, mime_type, file_hash)
|
|
33
|
+
* for compatibility with the registry merge algorithm.
|
|
34
|
+
*/
|
|
35
|
+
export interface ScannedFile {
|
|
36
|
+
/** Relative path from storage root (e.g. "projects/hero.jpg") */
|
|
37
|
+
path: string;
|
|
38
|
+
/** Filename only (e.g. "hero.jpg") */
|
|
39
|
+
filename: string;
|
|
40
|
+
/** File extension without dot (e.g. "jpg") */
|
|
41
|
+
extension: string;
|
|
42
|
+
/** File size in bytes */
|
|
43
|
+
file_size: number;
|
|
44
|
+
/** MIME type (e.g. "image/jpeg") */
|
|
45
|
+
mime_type: string;
|
|
46
|
+
/** Content hash if available (e.g. R2 ETag) */
|
|
47
|
+
file_hash?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================
|
|
51
|
+
// Storage Adapter interface
|
|
52
|
+
// ============================================
|
|
53
|
+
|
|
54
|
+
export interface StorageAdapter {
|
|
55
|
+
/** Which provider this adapter serves */
|
|
56
|
+
readonly provider: StorageProvider;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* List all media files recursively from the given root path.
|
|
60
|
+
* Returns a flat array sorted by path.
|
|
61
|
+
*
|
|
62
|
+
* @param rootPath - Subfolder to scan from (empty = root). Dropbox uses this
|
|
63
|
+
* as the API path; R2 uses it as a key prefix.
|
|
64
|
+
*/
|
|
65
|
+
listFiles(rootPath?: string): Promise<ScannedFile[]>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a relative asset path to a servable URL.
|
|
69
|
+
*
|
|
70
|
+
* - R2: returns a direct public URL (no API call needed).
|
|
71
|
+
* - Future providers may return temporary links or other URL types.
|
|
72
|
+
*
|
|
73
|
+
* @param relativePath - Path relative to storage root (e.g. "projects/hero.jpg")
|
|
74
|
+
* @returns Full URL the browser can fetch the asset from
|
|
75
|
+
*/
|
|
76
|
+
resolveUrl(relativePath: string): Promise<string>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Test whether the provider is reachable and credentials are valid.
|
|
80
|
+
*/
|
|
81
|
+
testConnection(): Promise<{ ok: boolean; error?: string }>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the current connection status for display in the admin UI.
|
|
85
|
+
*/
|
|
86
|
+
getStatus(): Promise<StorageProviderConfig>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ============================================
|
|
90
|
+
// MIME type utilities (shared across adapters)
|
|
91
|
+
// ============================================
|
|
92
|
+
|
|
93
|
+
const MIME_TYPES: Record<string, string> = {
|
|
94
|
+
jpg: "image/jpeg",
|
|
95
|
+
jpeg: "image/jpeg",
|
|
96
|
+
png: "image/png",
|
|
97
|
+
webp: "image/webp",
|
|
98
|
+
gif: "image/gif",
|
|
99
|
+
svg: "image/svg+xml",
|
|
100
|
+
mp4: "video/mp4",
|
|
101
|
+
webm: "video/webm",
|
|
102
|
+
mov: "video/quicktime",
|
|
103
|
+
pdf: "application/pdf",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Supported media file extensions (for filtering during scans) */
|
|
107
|
+
export const MEDIA_EXTENSIONS = new Set([
|
|
108
|
+
"jpg", "jpeg", "png", "webp", "gif", "svg",
|
|
109
|
+
"mp4", "webm", "mov",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/** Get MIME type from a filename or extension */
|
|
113
|
+
export function getMimeType(filenameOrExt: string): string {
|
|
114
|
+
const ext = filenameOrExt.includes(".")
|
|
115
|
+
? filenameOrExt.split(".").pop()?.toLowerCase() || ""
|
|
116
|
+
: filenameOrExt.toLowerCase();
|
|
117
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Check if a filename/key is a supported media file */
|
|
121
|
+
export function isMediaFile(key: string | undefined): boolean {
|
|
122
|
+
if (!key) return false;
|
|
123
|
+
const ext = key.split(".").pop()?.toLowerCase() || "";
|
|
124
|
+
return MEDIA_EXTENSIONS.has(ext);
|
|
125
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StylesProvider — Fetches site-wide styles from Sanity and generates
|
|
5
|
+
* @font-face declarations + CSS custom properties for the entire site.
|
|
6
|
+
*
|
|
7
|
+
* Usage: Wrap the root layout with <StylesProvider> to inject global styles.
|
|
8
|
+
* The builder reads DEFAULT_PAGE_SETTINGS from the store which can be
|
|
9
|
+
* initialized from these global styles.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
13
|
+
import type { SiteStyles } from "../../lib/sanity/types";
|
|
14
|
+
import {
|
|
15
|
+
sanitizeCssColor,
|
|
16
|
+
sanitizeCssNumeric,
|
|
17
|
+
sanitizeFontFamily,
|
|
18
|
+
sanitizeFontWeight,
|
|
19
|
+
sanitizeFontStyle,
|
|
20
|
+
sanitizeFontUrl,
|
|
21
|
+
} from "../../lib/security";
|
|
22
|
+
import { BREAKPOINTS, DEFAULT_GRID_WIDTH_PX } from "../../lib/builder/constants";
|
|
23
|
+
import { getSiteConfig } from "../../lib/config";
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// Context
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
const StylesContext = createContext<SiteStyles | null>(null);
|
|
30
|
+
|
|
31
|
+
export function useSiteStyles(): SiteStyles | null {
|
|
32
|
+
return useContext(StylesContext);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================
|
|
36
|
+
// Generate @font-face CSS from font data
|
|
37
|
+
// ============================================
|
|
38
|
+
|
|
39
|
+
function generateFontFaceCSS(styles: SiteStyles): string {
|
|
40
|
+
if (!styles.fonts) return "";
|
|
41
|
+
|
|
42
|
+
const faces: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const font of styles.fonts) {
|
|
45
|
+
const family = sanitizeFontFamily(font.family);
|
|
46
|
+
if (!family) continue;
|
|
47
|
+
|
|
48
|
+
for (const variant of font.variants || []) {
|
|
49
|
+
const fileUrl = sanitizeFontUrl(variant.file_url || "");
|
|
50
|
+
if (!fileUrl) continue;
|
|
51
|
+
|
|
52
|
+
const weight = sanitizeFontWeight(String(variant.weight));
|
|
53
|
+
const style = sanitizeFontStyle(variant.style || "normal");
|
|
54
|
+
|
|
55
|
+
// For built-in fonts, use local path; for uploaded fonts, use CDN URL
|
|
56
|
+
const src = font.is_builtin
|
|
57
|
+
? `url('${fileUrl}') format('woff2')`
|
|
58
|
+
: `url('${fileUrl}')`;
|
|
59
|
+
|
|
60
|
+
faces.push(`
|
|
61
|
+
@font-face {
|
|
62
|
+
font-family: '${family}';
|
|
63
|
+
src: ${src};
|
|
64
|
+
font-weight: ${weight};
|
|
65
|
+
font-style: ${style};
|
|
66
|
+
font-display: swap;
|
|
67
|
+
}`.trim());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return faces.join("\n\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================
|
|
75
|
+
// Generate brand token CSS (--brand-* variables)
|
|
76
|
+
// These are consumed by the @theme block in globals.css via var().
|
|
77
|
+
// Priority: admin palette (from styles) → site.config.ts palette.
|
|
78
|
+
// ============================================
|
|
79
|
+
|
|
80
|
+
function generateBrandTokens(styles: SiteStyles | null): string {
|
|
81
|
+
const cfg = getSiteConfig();
|
|
82
|
+
const pal = cfg.palette;
|
|
83
|
+
|
|
84
|
+
// Admin colors override config palette if present.
|
|
85
|
+
// Mapping: --brand-accent uses accentAlt (or accent fallback),
|
|
86
|
+
// --brand-accent-alt uses accent.
|
|
87
|
+
// When admin overrides accent via styles, that takes priority for --brand-accent.
|
|
88
|
+
const c = styles?.colors;
|
|
89
|
+
const accent = c?.accent || pal.accentAlt || pal.accent;
|
|
90
|
+
const accentAlt = pal.accent;
|
|
91
|
+
const secondary = c?.secondary || pal.secondary;
|
|
92
|
+
const primary = c?.primary || pal.primary;
|
|
93
|
+
const accent2 = pal.accent2 || pal.accent;
|
|
94
|
+
const text = c?.text || pal.text;
|
|
95
|
+
const muted = c?.muted || pal.muted;
|
|
96
|
+
const dark = pal.dark || pal.background;
|
|
97
|
+
|
|
98
|
+
// Font stack from config
|
|
99
|
+
const typo = cfg.typography;
|
|
100
|
+
const fontMono = `"${typo.defaultFont}", ${typo.monoFallback}`;
|
|
101
|
+
|
|
102
|
+
const props = [
|
|
103
|
+
`--brand-accent: ${sanitizeCssColor(accent, "#888888")}`,
|
|
104
|
+
`--brand-accent-alt: ${sanitizeCssColor(accentAlt, "#888888")}`,
|
|
105
|
+
`--brand-secondary: ${sanitizeCssColor(secondary, "#888888")}`,
|
|
106
|
+
`--brand-primary: ${sanitizeCssColor(primary, "#888888")}`,
|
|
107
|
+
`--brand-accent-2: ${sanitizeCssColor(accent2, "#888888")}`,
|
|
108
|
+
`--brand-text: ${sanitizeCssColor(text, "#ffffff")}`,
|
|
109
|
+
`--brand-muted: ${sanitizeCssColor(muted, "#888888")}`,
|
|
110
|
+
`--brand-dark: ${sanitizeCssColor(dark, "#111111")}`,
|
|
111
|
+
`--brand-font-mono: ${fontMono}`,
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
let css = `:root {\n ${props.join(";\n ")};\n}`;
|
|
115
|
+
|
|
116
|
+
// Built-in font @font-face from config (prevents FOUT before admin styles load)
|
|
117
|
+
const builtinFonts = typo.builtinFonts || [];
|
|
118
|
+
for (const font of builtinFonts) {
|
|
119
|
+
const family = sanitizeFontFamily(font.family);
|
|
120
|
+
if (!family) continue;
|
|
121
|
+
const src = sanitizeFontUrl(font.src);
|
|
122
|
+
if (!src) continue;
|
|
123
|
+
css += `\n@font-face {\n font-family: '${family}';\n src: url('${src}') format('woff2');\n font-weight: ${font.weight || "400"};\n font-style: ${font.style || "normal"};\n font-display: swap;\n}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return css;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
// ============================================
|
|
131
|
+
// Generate CSS custom properties from styles
|
|
132
|
+
// ============================================
|
|
133
|
+
|
|
134
|
+
function generateCSSProperties(styles: SiteStyles): string {
|
|
135
|
+
const cfg = getSiteConfig();
|
|
136
|
+
const pal = cfg.palette;
|
|
137
|
+
const props: string[] = [];
|
|
138
|
+
|
|
139
|
+
// Colors — generate CSS custom properties from palette swatches
|
|
140
|
+
if (styles.colors) {
|
|
141
|
+
// Palette swatches → --palette-0, --palette-1, etc.
|
|
142
|
+
if (styles.colors.swatches) {
|
|
143
|
+
for (let i = 0; i < styles.colors.swatches.length; i++) {
|
|
144
|
+
const swatch = styles.colors.swatches[i];
|
|
145
|
+
props.push(`--palette-${i}: ${sanitizeCssColor(swatch.hex, "#000000")}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Legacy color variables (backward compat for existing pages)
|
|
149
|
+
if (styles.colors.background) props.push(`--color-background: ${sanitizeCssColor(styles.colors.background, pal.background)}`);
|
|
150
|
+
if (styles.colors.text) props.push(`--color-text: ${sanitizeCssColor(styles.colors.text, pal.text)}`);
|
|
151
|
+
if (styles.colors.primary) props.push(`--color-primary: ${sanitizeCssColor(styles.colors.primary, pal.primary)}`);
|
|
152
|
+
if (styles.colors.secondary) props.push(`--color-secondary: ${sanitizeCssColor(styles.colors.secondary, pal.secondary)}`);
|
|
153
|
+
if (styles.colors.accent) props.push(`--color-accent: ${sanitizeCssColor(styles.colors.accent, pal.accent)}`);
|
|
154
|
+
if (styles.colors.muted) props.push(`--color-muted: ${sanitizeCssColor(styles.colors.muted, pal.muted)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Typography — sanitize each value type appropriately
|
|
158
|
+
if (styles.typography) {
|
|
159
|
+
for (const [level, t] of Object.entries(styles.typography)) {
|
|
160
|
+
if (!t) continue;
|
|
161
|
+
// Validate level name to prevent CSS injection via key
|
|
162
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(level)) continue;
|
|
163
|
+
if (t.font_family) {
|
|
164
|
+
const family = sanitizeFontFamily(t.font_family);
|
|
165
|
+
if (family) props.push(`--font-${level}: '${family}', monospace`);
|
|
166
|
+
}
|
|
167
|
+
props.push(`--text-${level}-size: ${sanitizeCssNumeric(t.font_size, "16px")}`);
|
|
168
|
+
props.push(`--text-${level}-weight: ${sanitizeFontWeight(String(t.font_weight), "400")}`);
|
|
169
|
+
props.push(`--text-${level}-lh: ${sanitizeCssNumeric(t.line_height, "1.5")}`);
|
|
170
|
+
props.push(`--text-${level}-ls: ${sanitizeCssNumeric(t.letter_spacing, "0")}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Links
|
|
175
|
+
if (styles.link_style) {
|
|
176
|
+
props.push(`--link-color: ${sanitizeCssColor(styles.link_style.color, pal.primary)}`);
|
|
177
|
+
props.push(`--link-hover: ${sanitizeCssColor(styles.link_style.hover_color, pal.secondary)}`);
|
|
178
|
+
props.push(`--link-underline: ${styles.link_style.underline ? "underline" : "none"}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Grid
|
|
182
|
+
if (styles.grid) {
|
|
183
|
+
const w = sanitizeCssNumeric(styles.grid.width + "px", DEFAULT_GRID_WIDTH_PX);
|
|
184
|
+
// Outer padding always 0 — padding is controlled per-row via Spacing TRBL
|
|
185
|
+
const pad = "0px";
|
|
186
|
+
const gutter = sanitizeCssNumeric(styles.grid.gutter_desktop + "px", "30px");
|
|
187
|
+
const gutterR = sanitizeCssNumeric(styles.grid.gutter_responsive + "px", "30px");
|
|
188
|
+
props.push(`--grid-width: ${w}`);
|
|
189
|
+
props.push(`--grid-padding: ${pad}`);
|
|
190
|
+
props.push(`--grid-gutter: ${gutter}`);
|
|
191
|
+
props.push(`--grid-gutter-responsive: ${gutterR}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Buttons
|
|
195
|
+
if (styles.button_style) {
|
|
196
|
+
props.push(`--btn-primary-bg: ${sanitizeCssColor(styles.button_style.primary_bg, pal.primary)}`);
|
|
197
|
+
props.push(`--btn-primary-text: ${sanitizeCssColor(styles.button_style.primary_text, "#ffffff")}`);
|
|
198
|
+
props.push(`--btn-secondary-bg: ${sanitizeCssColor(styles.button_style.secondary_bg, "#ffffff")}`);
|
|
199
|
+
props.push(`--btn-secondary-text: ${sanitizeCssColor(styles.button_style.secondary_text, "#000000")}`);
|
|
200
|
+
props.push(`--btn-radius: ${sanitizeCssNumeric(styles.button_style.border_radius, "0px")}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (props.length === 0 && !styles.disable_scroll_animations_mobile) return "";
|
|
204
|
+
|
|
205
|
+
let css = props.length > 0 ? `:root {\n ${props.join(";\n ")};\n}` : "";
|
|
206
|
+
|
|
207
|
+
// Responsive gutter overrides: tablet (≤1024px) and phone (≤640px)
|
|
208
|
+
if (styles.grid) {
|
|
209
|
+
const gutterTablet = sanitizeCssNumeric(styles.grid.gutter_responsive + "px", "30px");
|
|
210
|
+
const gutterPhone = sanitizeCssNumeric((styles.grid.gutter_phone || "16") + "px", "16px");
|
|
211
|
+
css += `\n@media (max-width: ${BREAKPOINTS.tablet}px) {\n :root { --grid-gutter: ${gutterTablet}; }\n}`;
|
|
212
|
+
css += `\n@media (max-width: ${BREAKPOINTS.phone}px) {\n :root { --grid-gutter: ${gutterPhone}; }\n}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Global mobile disable for enter animations (§3.7)
|
|
216
|
+
if (styles.disable_scroll_animations_mobile) {
|
|
217
|
+
css += `\n@media (max-width: ${BREAKPOINTS.mobileAnimation}px) {\n [data-enter-animation] {\n animation: none !important;\n opacity: 1 !important;\n transform: none !important;\n filter: none !important;\n clip-path: none !important;\n }\n}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return css;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================
|
|
224
|
+
// Provider component
|
|
225
|
+
// ============================================
|
|
226
|
+
|
|
227
|
+
export function StylesProvider({ children }: { children: React.ReactNode }) {
|
|
228
|
+
const [styles, setStyles] = useState<SiteStyles | null>(null);
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
// Fetch styles from public API (no auth needed for rendering)
|
|
232
|
+
fetch("/api/styles")
|
|
233
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
234
|
+
.then((data) => {
|
|
235
|
+
if (data?.styles) setStyles(data.styles);
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {
|
|
238
|
+
// Silently fail — site works without custom styles
|
|
239
|
+
});
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
// Brand tokens from config (immediate, no fetch needed).
|
|
243
|
+
// These set --brand-* variables consumed by the @theme block in globals.css.
|
|
244
|
+
// Once admin styles load, generateCSSProperties may further override some values.
|
|
245
|
+
const brandTokensCSS = generateBrandTokens(styles);
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<StylesContext.Provider value={styles}>
|
|
249
|
+
{/* Brand tokens — always injected (config fallback until admin styles load) */}
|
|
250
|
+
<style dangerouslySetInnerHTML={{ __html: brandTokensCSS }} />
|
|
251
|
+
{/* Admin styles — layered on top after fetch */}
|
|
252
|
+
{styles && (
|
|
253
|
+
<style
|
|
254
|
+
dangerouslySetInnerHTML={{
|
|
255
|
+
__html: [
|
|
256
|
+
generateFontFaceCSS(styles),
|
|
257
|
+
generateCSSProperties(styles),
|
|
258
|
+
]
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.join("\n\n"),
|
|
261
|
+
}}
|
|
262
|
+
/>
|
|
263
|
+
)}
|
|
264
|
+
{children}
|
|
265
|
+
</StylesContext.Provider>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side thumbnail generator for R2 uploads.
|
|
3
|
+
*
|
|
4
|
+
* Uses browser-native Canvas + createImageBitmap APIs to resize images
|
|
5
|
+
* to 1400px max dimension JPEG thumbnails. Zero Vercel processing.
|
|
6
|
+
*
|
|
7
|
+
* Matches the existing thumbnail spec from tools/builder-thumbs/:
|
|
8
|
+
* - Max dimension: 1400px (longest edge, maintains aspect ratio)
|
|
9
|
+
* - Format: JPEG (.jpg)
|
|
10
|
+
* - Quality: 0.80
|
|
11
|
+
* - EXIF rotation: automatic via createImageBitmap
|
|
12
|
+
* - Typical output: 80–180 KB
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── Raster extensions eligible for thumbnails ──
|
|
16
|
+
const RASTER_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"]);
|
|
17
|
+
|
|
18
|
+
/** Maximum pixels on the longest edge */
|
|
19
|
+
const MAX_DIMENSION = 1400;
|
|
20
|
+
|
|
21
|
+
/** JPEG quality (0–1) */
|
|
22
|
+
const JPEG_QUALITY = 0.80;
|
|
23
|
+
|
|
24
|
+
// ── Types ──
|
|
25
|
+
|
|
26
|
+
export interface ThumbnailResult {
|
|
27
|
+
blob: Blob;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Public API ──
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check whether a filename is a raster image eligible for thumbnails.
|
|
36
|
+
* Returns false for svg, mp4, pdf, etc.
|
|
37
|
+
*/
|
|
38
|
+
export function isRasterImage(filename: string): boolean {
|
|
39
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
40
|
+
return RASTER_EXTENSIONS.has(ext);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute the `_thumbs/` key for a given asset path.
|
|
45
|
+
*
|
|
46
|
+
* Example:
|
|
47
|
+
* thumbKeyForPath("projects/house-of-delights/hero.png")
|
|
48
|
+
* → "_thumbs/projects/house-of-delights/hero.jpg"
|
|
49
|
+
*/
|
|
50
|
+
export function thumbKeyForPath(assetPath: string): string {
|
|
51
|
+
const dotIndex = assetPath.lastIndexOf(".");
|
|
52
|
+
const pathWithoutExt = dotIndex > 0 ? assetPath.slice(0, dotIndex) : assetPath;
|
|
53
|
+
return `_thumbs/${pathWithoutExt}.jpg`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Generate a JPEG thumbnail from a File.
|
|
58
|
+
*
|
|
59
|
+
* Returns null for non-raster files or if generation fails
|
|
60
|
+
* (e.g., Canvas memory exhaustion on very large images).
|
|
61
|
+
*
|
|
62
|
+
* @param file - The uploaded File object
|
|
63
|
+
* @returns ThumbnailResult with JPEG blob and dimensions, or null
|
|
64
|
+
*/
|
|
65
|
+
export async function generateThumbnail(
|
|
66
|
+
file: File
|
|
67
|
+
): Promise<ThumbnailResult | null> {
|
|
68
|
+
// Only process raster images
|
|
69
|
+
if (!isRasterImage(file.name)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// createImageBitmap decodes the image and applies EXIF rotation automatically.
|
|
75
|
+
// For animated GIFs, it extracts the first frame (default behavior).
|
|
76
|
+
const bitmap = await createImageBitmap(file);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const { width: srcW, height: srcH } = bitmap;
|
|
80
|
+
|
|
81
|
+
// Calculate target dimensions (fit within MAX_DIMENSION box)
|
|
82
|
+
let targetW = srcW;
|
|
83
|
+
let targetH = srcH;
|
|
84
|
+
|
|
85
|
+
if (srcW > MAX_DIMENSION || srcH > MAX_DIMENSION) {
|
|
86
|
+
if (srcW >= srcH) {
|
|
87
|
+
targetW = MAX_DIMENSION;
|
|
88
|
+
targetH = Math.round((srcH / srcW) * MAX_DIMENSION);
|
|
89
|
+
} else {
|
|
90
|
+
targetH = MAX_DIMENSION;
|
|
91
|
+
targetW = Math.round((srcW / srcH) * MAX_DIMENSION);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Ensure minimum 1px dimensions
|
|
96
|
+
targetW = Math.max(1, targetW);
|
|
97
|
+
targetH = Math.max(1, targetH);
|
|
98
|
+
|
|
99
|
+
// Draw onto canvas and export as JPEG
|
|
100
|
+
const blob = await renderToJpeg(bitmap, targetW, targetH);
|
|
101
|
+
if (!blob) return null;
|
|
102
|
+
|
|
103
|
+
return { blob, width: targetW, height: targetH };
|
|
104
|
+
} finally {
|
|
105
|
+
bitmap.close();
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Canvas memory exhaustion on very large images (>50MP), or
|
|
109
|
+
// createImageBitmap failures on corrupted files — graceful fallback.
|
|
110
|
+
console.warn("[generateThumbnail] Failed for", file.name, err);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Internal helpers ──
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render an ImageBitmap to a JPEG Blob via OffscreenCanvas (or Canvas fallback).
|
|
119
|
+
*/
|
|
120
|
+
async function renderToJpeg(
|
|
121
|
+
bitmap: ImageBitmap,
|
|
122
|
+
width: number,
|
|
123
|
+
height: number
|
|
124
|
+
): Promise<Blob | null> {
|
|
125
|
+
// Prefer OffscreenCanvas (Chrome, Firefox, Safari 16.4+)
|
|
126
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
127
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
128
|
+
const ctx = canvas.getContext("2d");
|
|
129
|
+
if (!ctx) return null;
|
|
130
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
131
|
+
return canvas.convertToBlob({ type: "image/jpeg", quality: JPEG_QUALITY });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fallback: regular Canvas (older Safari)
|
|
135
|
+
return new Promise<Blob | null>((resolve) => {
|
|
136
|
+
const canvas = document.createElement("canvas");
|
|
137
|
+
canvas.width = width;
|
|
138
|
+
canvas.height = height;
|
|
139
|
+
const ctx = canvas.getContext("2d");
|
|
140
|
+
if (!ctx) {
|
|
141
|
+
resolve(null);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
145
|
+
canvas.toBlob(
|
|
146
|
+
(blob) => resolve(blob),
|
|
147
|
+
"image/jpeg",
|
|
148
|
+
JPEG_QUALITY
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
}
|