@jant/core 0.2.4 → 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/client/.vite/manifest.json +17 -0
- package/dist/client/assets/client-DKznU4dt.js +3434 -0
- package/dist/client/assets/style-D2PQnJEV.css +2 -0
- package/dist/client.d.ts +3 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- package/dist/jant/.dev.vars +1 -0
- package/dist/jant/.vite/manifest.json +58 -0
- package/dist/jant/assets/bun-sqlite-dialect-DmKw6ndf.js +155 -0
- package/dist/jant/assets/index-1wipnXVC.js +212 -0
- package/dist/jant/assets/index-DhnBrk-n.js +295 -0
- package/dist/jant/assets/node-sqlite-dialect-BzlVRd6O.js +155 -0
- package/dist/jant/assets/worker-entry-CAzIsRGm.js +66701 -0
- package/dist/jant/index.js +2 -0
- package/dist/jant/wrangler.json +1 -0
- package/dist/lib/image-processor.d.ts +30 -0
- package/dist/lib/image-processor.d.ts.map +1 -0
- package/dist/lib/image-processor.js +191 -0
- package/dist/routes/dash/media.d.ts.map +1 -1
- package/dist/routes/dash/media.js +2 -8
- package/dist/theme/layouts/BaseLayout.d.ts +2 -0
- package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
- package/dist/theme/layouts/BaseLayout.js +9 -10
- package/package.json +3 -3
- package/src/client.ts +3 -4
- package/src/lib/image-processor.ts +218 -0
- package/src/preset.css +10 -2
- package/src/routes/dash/media.tsx +1 -7
- package/src/style.css +15 -0
- package/src/theme/layouts/BaseLayout.tsx +6 -8
- package/src/lib/assets.ts +0 -49
- package/static/assets/image-processor.js +0 -234
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"configPath":"/Users/green/project/jant/packages/core/wrangler.toml","userConfigPath":"/Users/green/project/jant/packages/core/wrangler.toml","topLevelName":"jant","definedEnvironments":[],"legacy_env":true,"compatibility_date":"2026-01-20","compatibility_flags":["nodejs_compat"],"jsx_factory":"React.createElement","jsx_fragment":"React.Fragment","rules":[{"type":"ESModule","globs":["**/*.js","**/*.mjs"]}],"name":"jant","main":"index.js","triggers":{},"assets":{"directory":"../client"},"vars":{"SITE_URL":"https://local.jant.me"},"durable_objects":{"bindings":[]},"workflows":[],"migrations":[],"kv_namespaces":[],"cloudchamber":{},"send_email":[],"queues":{"producers":[],"consumers":[]},"r2_buckets":[{"binding":"R2","bucket_name":"jant-media"}],"d1_databases":[{"binding":"DB","database_name":"jant-db","database_id":"local","migrations_dir":"src/db/migrations"}],"vectorize":[],"hyperdrive":[],"services":[],"analytics_engine_datasets":[],"dispatch_namespaces":[],"mtls_certificates":[],"pipelines":[],"secrets_store_secrets":[],"unsafe_hello_world":[],"worker_loaders":[],"ratelimits":[],"vpc_services":[],"logfwdr":{"bindings":[]},"python_modules":{"exclude":["**/*.pyc"]},"dev":{"ip":"localhost","port":9019,"local_protocol":"http","upstream_protocol":"http","enable_containers":true,"generate_types":false},"no_bundle":true}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Image Processor
|
|
3
|
+
*
|
|
4
|
+
* Processes images before upload:
|
|
5
|
+
* - Corrects EXIF orientation
|
|
6
|
+
* - Resizes to max dimensions
|
|
7
|
+
* - Strips all metadata (privacy)
|
|
8
|
+
* - Converts to WebP format
|
|
9
|
+
*/
|
|
10
|
+
declare const DEFAULT_OPTIONS: {
|
|
11
|
+
maxWidth: number;
|
|
12
|
+
maxHeight: number;
|
|
13
|
+
quality: number;
|
|
14
|
+
mimeType: "image/webp";
|
|
15
|
+
};
|
|
16
|
+
type ProcessOptions = Partial<typeof DEFAULT_OPTIONS>;
|
|
17
|
+
/**
|
|
18
|
+
* Process image file
|
|
19
|
+
*/
|
|
20
|
+
declare function process(file: File, options?: ProcessOptions): Promise<Blob>;
|
|
21
|
+
/**
|
|
22
|
+
* Process file and create a new File object
|
|
23
|
+
*/
|
|
24
|
+
declare function processToFile(file: File, options?: ProcessOptions): Promise<File>;
|
|
25
|
+
export declare const ImageProcessor: {
|
|
26
|
+
process: typeof process;
|
|
27
|
+
processToFile: typeof processToFile;
|
|
28
|
+
};
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=image-processor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-processor.d.ts","sourceRoot":"","sources":["../../src/lib/image-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAA,MAAM,eAAe;;;;;CAKpB,CAAC;AAEF,KAAK,cAAc,GAAG,OAAO,CAAC,OAAO,eAAe,CAAC,CAAC;AAiHtD;;GAEG;AACH,iBAAe,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgE9E;AAED;;GAEG;AACH,iBAAe,aAAa,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAQpF;AAED,eAAO,MAAM,cAAc;;;CAA6B,CAAC"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Image Processor
|
|
3
|
+
*
|
|
4
|
+
* Processes images before upload:
|
|
5
|
+
* - Corrects EXIF orientation
|
|
6
|
+
* - Resizes to max dimensions
|
|
7
|
+
* - Strips all metadata (privacy)
|
|
8
|
+
* - Converts to WebP format
|
|
9
|
+
*/ const DEFAULT_OPTIONS = {
|
|
10
|
+
maxWidth: 1920,
|
|
11
|
+
maxHeight: 1920,
|
|
12
|
+
quality: 0.85,
|
|
13
|
+
mimeType: "image/webp"
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* EXIF Orientation values and their transformations
|
|
17
|
+
*/ const ORIENTATIONS = {
|
|
18
|
+
1: {
|
|
19
|
+
rotate: 0,
|
|
20
|
+
flip: false
|
|
21
|
+
},
|
|
22
|
+
2: {
|
|
23
|
+
rotate: 0,
|
|
24
|
+
flip: true
|
|
25
|
+
},
|
|
26
|
+
3: {
|
|
27
|
+
rotate: 180,
|
|
28
|
+
flip: false
|
|
29
|
+
},
|
|
30
|
+
4: {
|
|
31
|
+
rotate: 180,
|
|
32
|
+
flip: true
|
|
33
|
+
},
|
|
34
|
+
5: {
|
|
35
|
+
rotate: 90,
|
|
36
|
+
flip: true
|
|
37
|
+
},
|
|
38
|
+
6: {
|
|
39
|
+
rotate: 90,
|
|
40
|
+
flip: false
|
|
41
|
+
},
|
|
42
|
+
7: {
|
|
43
|
+
rotate: 270,
|
|
44
|
+
flip: true
|
|
45
|
+
},
|
|
46
|
+
8: {
|
|
47
|
+
rotate: 270,
|
|
48
|
+
flip: false
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Read EXIF orientation from JPEG file
|
|
53
|
+
*/ function readExifOrientation(buffer) {
|
|
54
|
+
const view = new DataView(buffer);
|
|
55
|
+
// Check for JPEG SOI marker
|
|
56
|
+
if (view.getUint16(0) !== 0xffd8) return 1;
|
|
57
|
+
let offset = 2;
|
|
58
|
+
const length = view.byteLength;
|
|
59
|
+
while(offset < length){
|
|
60
|
+
if (view.getUint8(offset) !== 0xff) return 1;
|
|
61
|
+
const marker = view.getUint8(offset + 1);
|
|
62
|
+
// APP1 marker (EXIF)
|
|
63
|
+
if (marker === 0xe1) {
|
|
64
|
+
const exifOffset = offset + 4;
|
|
65
|
+
// Check for "Exif\0\0"
|
|
66
|
+
if (view.getUint32(exifOffset) !== 0x45786966 || view.getUint16(exifOffset + 4) !== 0x0000) {
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
const tiffOffset = exifOffset + 6;
|
|
70
|
+
const littleEndian = view.getUint16(tiffOffset) === 0x4949;
|
|
71
|
+
// Validate TIFF header
|
|
72
|
+
if (view.getUint16(tiffOffset + 2, littleEndian) !== 0x002a) return 1;
|
|
73
|
+
const ifdOffset = view.getUint32(tiffOffset + 4, littleEndian);
|
|
74
|
+
const numEntries = view.getUint16(tiffOffset + ifdOffset, littleEndian);
|
|
75
|
+
// Search for orientation tag (0x0112)
|
|
76
|
+
for(let i = 0; i < numEntries; i++){
|
|
77
|
+
const entryOffset = tiffOffset + ifdOffset + 2 + i * 12;
|
|
78
|
+
const tag = view.getUint16(entryOffset, littleEndian);
|
|
79
|
+
if (tag === 0x0112) {
|
|
80
|
+
return view.getUint16(entryOffset + 8, littleEndian);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
// Skip to next marker
|
|
86
|
+
if (marker === 0xd8 || marker === 0xd9) {
|
|
87
|
+
offset += 2;
|
|
88
|
+
} else {
|
|
89
|
+
offset += 2 + view.getUint16(offset + 2);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Load image from file
|
|
96
|
+
*/ function loadImage(file) {
|
|
97
|
+
return new Promise((resolve, reject)=>{
|
|
98
|
+
const img = new Image();
|
|
99
|
+
img.onload = ()=>{
|
|
100
|
+
URL.revokeObjectURL(img.src);
|
|
101
|
+
resolve(img);
|
|
102
|
+
};
|
|
103
|
+
img.onerror = ()=>reject(new Error("Failed to load image"));
|
|
104
|
+
img.src = URL.createObjectURL(file);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Calculate output dimensions maintaining aspect ratio
|
|
109
|
+
*/ function calculateDimensions(width, height, maxWidth, maxHeight) {
|
|
110
|
+
if (width <= maxWidth && height <= maxHeight) {
|
|
111
|
+
return {
|
|
112
|
+
width,
|
|
113
|
+
height
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
|
117
|
+
return {
|
|
118
|
+
width: Math.round(width * ratio),
|
|
119
|
+
height: Math.round(height * ratio)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Process image file
|
|
124
|
+
*/ async function process(file, options = {}) {
|
|
125
|
+
const opts = {
|
|
126
|
+
...DEFAULT_OPTIONS,
|
|
127
|
+
...options
|
|
128
|
+
};
|
|
129
|
+
// Read file buffer for EXIF
|
|
130
|
+
const buffer = await file.arrayBuffer();
|
|
131
|
+
const orientation = readExifOrientation(buffer);
|
|
132
|
+
const transform = ORIENTATIONS[orientation] || ORIENTATIONS[1];
|
|
133
|
+
// Load image
|
|
134
|
+
const img = await loadImage(file);
|
|
135
|
+
// For 90° or 270° rotation, swap dimensions
|
|
136
|
+
const isRotated = transform.rotate === 90 || transform.rotate === 270;
|
|
137
|
+
const srcWidth = isRotated ? img.height : img.width;
|
|
138
|
+
const srcHeight = isRotated ? img.width : img.height;
|
|
139
|
+
// Calculate output size
|
|
140
|
+
const { width, height } = calculateDimensions(srcWidth, srcHeight, opts.maxWidth, opts.maxHeight);
|
|
141
|
+
// Create canvas
|
|
142
|
+
const canvas = document.createElement("canvas");
|
|
143
|
+
canvas.width = width;
|
|
144
|
+
canvas.height = height;
|
|
145
|
+
const ctx = canvas.getContext("2d");
|
|
146
|
+
if (!ctx) throw new Error("Failed to get canvas context");
|
|
147
|
+
// Apply transformations
|
|
148
|
+
ctx.save();
|
|
149
|
+
ctx.translate(width / 2, height / 2);
|
|
150
|
+
if (transform.rotate) {
|
|
151
|
+
ctx.rotate(transform.rotate * Math.PI / 180);
|
|
152
|
+
}
|
|
153
|
+
if (transform.flip) {
|
|
154
|
+
ctx.scale(-1, 1);
|
|
155
|
+
}
|
|
156
|
+
const drawWidth = isRotated ? height : width;
|
|
157
|
+
const drawHeight = isRotated ? width : height;
|
|
158
|
+
ctx.drawImage(img, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
|
|
159
|
+
ctx.restore();
|
|
160
|
+
// Export as WebP
|
|
161
|
+
return new Promise((resolve, reject)=>{
|
|
162
|
+
canvas.toBlob((blob)=>{
|
|
163
|
+
if (blob) {
|
|
164
|
+
resolve(blob);
|
|
165
|
+
} else {
|
|
166
|
+
reject(new Error("Failed to create blob"));
|
|
167
|
+
}
|
|
168
|
+
}, opts.mimeType, opts.quality);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Process file and create a new File object
|
|
173
|
+
*/ async function processToFile(file, options = {}) {
|
|
174
|
+
const blob = await process(file, options);
|
|
175
|
+
// Generate new filename with .webp extension
|
|
176
|
+
const originalName = file.name.replace(/\.[^.]+$/, "");
|
|
177
|
+
const newName = `${originalName}.webp`;
|
|
178
|
+
return new File([
|
|
179
|
+
blob
|
|
180
|
+
], newName, {
|
|
181
|
+
type: "image/webp"
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
export const ImageProcessor = {
|
|
185
|
+
process,
|
|
186
|
+
processToFile
|
|
187
|
+
};
|
|
188
|
+
// Expose globally for inline scripts
|
|
189
|
+
if (typeof window !== "undefined") {
|
|
190
|
+
window.ImageProcessor = ImageProcessor;
|
|
191
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/media.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAS,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/media.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAS,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAMjD,KAAK,GAAG,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3D,eAAO,MAAM,WAAW,kDAAkB,CAAC"}
|
|
@@ -10,7 +10,6 @@ import { DashLayout } from "../../theme/layouts/index.js";
|
|
|
10
10
|
import { EmptyState, DangerZone } from "../../theme/components/index.js";
|
|
11
11
|
import * as time from "../../lib/time.js";
|
|
12
12
|
import { getMediaUrl, getImageUrl } from "../../lib/image.js";
|
|
13
|
-
import { getAssets } from "../../lib/assets.js";
|
|
14
13
|
export const mediaRoutes = new Hono();
|
|
15
14
|
/**
|
|
16
15
|
* Format file size for display
|
|
@@ -73,7 +72,7 @@ export const mediaRoutes = new Hono();
|
|
|
73
72
|
*
|
|
74
73
|
* Uses plain JavaScript for upload state management (more reliable than Datastar signals
|
|
75
74
|
* for complex async flows like file uploads with SSE responses).
|
|
76
|
-
*/ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl
|
|
75
|
+
*/ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl }) {
|
|
77
76
|
const { t } = useLingui();
|
|
78
77
|
const processingText = t({
|
|
79
78
|
message: "Processing...",
|
|
@@ -245,9 +244,6 @@ function processSSEEvent(event) {
|
|
|
245
244
|
`.trim();
|
|
246
245
|
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
247
246
|
children: [
|
|
248
|
-
/*#__PURE__*/ _jsx("script", {
|
|
249
|
-
src: imageProcessorUrl
|
|
250
|
-
}),
|
|
251
247
|
/*#__PURE__*/ _jsx("script", {
|
|
252
248
|
dangerouslySetInnerHTML: {
|
|
253
249
|
__html: uploadScript
|
|
@@ -535,7 +531,6 @@ mediaRoutes.get("/", async (c)=>{
|
|
|
535
531
|
const siteName = await c.var.services.settings.get("SITE_NAME") ?? "Jant";
|
|
536
532
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
537
533
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
538
|
-
const assets = getAssets();
|
|
539
534
|
return c.html(/*#__PURE__*/ _jsx(DashLayout, {
|
|
540
535
|
c: c,
|
|
541
536
|
title: "Media",
|
|
@@ -544,8 +539,7 @@ mediaRoutes.get("/", async (c)=>{
|
|
|
544
539
|
children: /*#__PURE__*/ _jsx(MediaListContent, {
|
|
545
540
|
mediaList: mediaList,
|
|
546
541
|
r2PublicUrl: r2PublicUrl,
|
|
547
|
-
imageTransformUrl: imageTransformUrl
|
|
548
|
-
imageProcessorUrl: assets.imageProcessor
|
|
542
|
+
imageTransformUrl: imageTransformUrl
|
|
549
543
|
})
|
|
550
544
|
}));
|
|
551
545
|
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides the HTML shell with meta tags, styles, and scripts.
|
|
5
5
|
* If Context is provided, automatically wraps children with I18nProvider.
|
|
6
|
+
*
|
|
7
|
+
* Uses vite-ssr-components for automatic dev/prod asset path resolution.
|
|
6
8
|
*/
|
|
7
9
|
import type { FC, PropsWithChildren } from "hono/jsx";
|
|
8
10
|
import type { Context } from "hono";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BaseLayout.d.ts","sourceRoot":"","sources":["../../../src/theme/layouts/BaseLayout.tsx"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"BaseLayout.d.ts","sourceRoot":"","sources":["../../../src/theme/layouts/BaseLayout.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,EAAE,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAIpC,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,CAAC,EAAE,OAAO,CAAC;CACb;AAED,eAAO,MAAM,UAAU,EAAE,EAAE,CAAC,iBAAiB,CAAC,eAAe,CAAC,CA0B7D,CAAC"}
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides the HTML shell with meta tags, styles, and scripts.
|
|
5
5
|
* If Context is provided, automatically wraps children with I18nProvider.
|
|
6
|
+
*
|
|
7
|
+
* Uses vite-ssr-components for automatic dev/prod asset path resolution.
|
|
6
8
|
*/ import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
|
|
7
|
-
import {
|
|
9
|
+
import { Script, Link, ViteClient } from "vite-ssr-components/hono";
|
|
8
10
|
import { I18nProvider } from "../../i18n/index.js";
|
|
9
11
|
export const BaseLayout = ({ title, description, lang = "en", c, children })=>{
|
|
10
|
-
// Get assets at render time (supports runtime manifest loading)
|
|
11
|
-
const assets = getAssets();
|
|
12
12
|
// Automatically wrap with I18nProvider if Context is provided
|
|
13
13
|
const content = c ? /*#__PURE__*/ _jsx(I18nProvider, {
|
|
14
14
|
c: c,
|
|
@@ -33,14 +33,13 @@ export const BaseLayout = ({ title, description, lang = "en", c, children })=>{
|
|
|
33
33
|
name: "description",
|
|
34
34
|
content: description
|
|
35
35
|
}),
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
href:
|
|
36
|
+
/*#__PURE__*/ _jsx(ViteClient, {}),
|
|
37
|
+
/*#__PURE__*/ _jsx(Link, {
|
|
38
|
+
href: "/src/style.css",
|
|
39
|
+
rel: "stylesheet"
|
|
39
40
|
}),
|
|
40
|
-
/*#__PURE__*/ _jsx(
|
|
41
|
-
|
|
42
|
-
src: assets.client,
|
|
43
|
-
defer: true
|
|
41
|
+
/*#__PURE__*/ _jsx(Script, {
|
|
42
|
+
src: "/src/client.ts"
|
|
44
43
|
})
|
|
45
44
|
]
|
|
46
45
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jant/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "A modern, open-source microblogging platform built on Cloudflare Workers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"bin",
|
|
24
24
|
"dist",
|
|
25
|
-
"static",
|
|
26
25
|
"migrations",
|
|
27
26
|
"src"
|
|
28
27
|
],
|
|
@@ -72,6 +71,7 @@
|
|
|
72
71
|
"typescript": "^5.9.3",
|
|
73
72
|
"unplugin-swc": "^1.5.9",
|
|
74
73
|
"vite": "^7.3.1",
|
|
74
|
+
"vite-ssr-components": "^0.5.2",
|
|
75
75
|
"wrangler": "^4.61.1"
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"build:types": "tsc -p tsconfig.build.json",
|
|
106
106
|
"deploy": "pnpm build && wrangler deploy",
|
|
107
107
|
"preview": "vite preview",
|
|
108
|
-
"typecheck": "tsc --noEmit",
|
|
108
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.client.json",
|
|
109
109
|
"lint": "eslint src/",
|
|
110
110
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
|
111
111
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
package/src/client.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Bundles all interactive components:
|
|
5
5
|
* - Datastar (reactivity)
|
|
6
|
-
* - BaseCoat (dialogs, dropdowns
|
|
6
|
+
* - BaseCoat (dialogs, dropdowns)
|
|
7
|
+
* - ImageProcessor (media uploads)
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
// Datastar for reactivity (data-* attributes)
|
|
10
10
|
import "@sudodevnull/datastar";
|
|
11
|
-
|
|
12
|
-
// BaseCoat interactive components
|
|
13
11
|
import "basecoat-css/all";
|
|
12
|
+
import "./lib/image-processor.js";
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Image Processor
|
|
3
|
+
*
|
|
4
|
+
* Processes images before upload:
|
|
5
|
+
* - Corrects EXIF orientation
|
|
6
|
+
* - Resizes to max dimensions
|
|
7
|
+
* - Strips all metadata (privacy)
|
|
8
|
+
* - Converts to WebP format
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_OPTIONS = {
|
|
12
|
+
maxWidth: 1920,
|
|
13
|
+
maxHeight: 1920,
|
|
14
|
+
quality: 0.85,
|
|
15
|
+
mimeType: "image/webp" as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ProcessOptions = Partial<typeof DEFAULT_OPTIONS>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* EXIF Orientation values and their transformations
|
|
22
|
+
*/
|
|
23
|
+
const ORIENTATIONS: Record<number, { rotate: number; flip: boolean }> = {
|
|
24
|
+
1: { rotate: 0, flip: false }, // Normal
|
|
25
|
+
2: { rotate: 0, flip: true }, // Flipped horizontally
|
|
26
|
+
3: { rotate: 180, flip: false }, // Rotated 180°
|
|
27
|
+
4: { rotate: 180, flip: true }, // Flipped vertically
|
|
28
|
+
5: { rotate: 90, flip: true }, // Rotated 90° CCW + flipped
|
|
29
|
+
6: { rotate: 90, flip: false }, // Rotated 90° CW
|
|
30
|
+
7: { rotate: 270, flip: true }, // Rotated 90° CW + flipped
|
|
31
|
+
8: { rotate: 270, flip: false }, // Rotated 90° CCW
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read EXIF orientation from JPEG file
|
|
36
|
+
*/
|
|
37
|
+
function readExifOrientation(buffer: ArrayBuffer): number {
|
|
38
|
+
const view = new DataView(buffer);
|
|
39
|
+
|
|
40
|
+
// Check for JPEG SOI marker
|
|
41
|
+
if (view.getUint16(0) !== 0xffd8) return 1;
|
|
42
|
+
|
|
43
|
+
let offset = 2;
|
|
44
|
+
const length = view.byteLength;
|
|
45
|
+
|
|
46
|
+
while (offset < length) {
|
|
47
|
+
if (view.getUint8(offset) !== 0xff) return 1;
|
|
48
|
+
|
|
49
|
+
const marker = view.getUint8(offset + 1);
|
|
50
|
+
|
|
51
|
+
// APP1 marker (EXIF)
|
|
52
|
+
if (marker === 0xe1) {
|
|
53
|
+
const exifOffset = offset + 4;
|
|
54
|
+
|
|
55
|
+
// Check for "Exif\0\0"
|
|
56
|
+
if (
|
|
57
|
+
view.getUint32(exifOffset) !== 0x45786966 ||
|
|
58
|
+
view.getUint16(exifOffset + 4) !== 0x0000
|
|
59
|
+
) {
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tiffOffset = exifOffset + 6;
|
|
64
|
+
const littleEndian = view.getUint16(tiffOffset) === 0x4949;
|
|
65
|
+
|
|
66
|
+
// Validate TIFF header
|
|
67
|
+
if (view.getUint16(tiffOffset + 2, littleEndian) !== 0x002a) return 1;
|
|
68
|
+
|
|
69
|
+
const ifdOffset = view.getUint32(tiffOffset + 4, littleEndian);
|
|
70
|
+
const numEntries = view.getUint16(tiffOffset + ifdOffset, littleEndian);
|
|
71
|
+
|
|
72
|
+
// Search for orientation tag (0x0112)
|
|
73
|
+
for (let i = 0; i < numEntries; i++) {
|
|
74
|
+
const entryOffset = tiffOffset + ifdOffset + 2 + i * 12;
|
|
75
|
+
const tag = view.getUint16(entryOffset, littleEndian);
|
|
76
|
+
|
|
77
|
+
if (tag === 0x0112) {
|
|
78
|
+
return view.getUint16(entryOffset + 8, littleEndian);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Skip to next marker
|
|
86
|
+
if (marker === 0xd8 || marker === 0xd9) {
|
|
87
|
+
offset += 2;
|
|
88
|
+
} else {
|
|
89
|
+
offset += 2 + view.getUint16(offset + 2);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load image from file
|
|
98
|
+
*/
|
|
99
|
+
function loadImage(file: File): Promise<HTMLImageElement> {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const img = new Image();
|
|
102
|
+
img.onload = () => {
|
|
103
|
+
URL.revokeObjectURL(img.src);
|
|
104
|
+
resolve(img);
|
|
105
|
+
};
|
|
106
|
+
img.onerror = () => reject(new Error("Failed to load image"));
|
|
107
|
+
img.src = URL.createObjectURL(file);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Calculate output dimensions maintaining aspect ratio
|
|
113
|
+
*/
|
|
114
|
+
function calculateDimensions(
|
|
115
|
+
width: number,
|
|
116
|
+
height: number,
|
|
117
|
+
maxWidth: number,
|
|
118
|
+
maxHeight: number
|
|
119
|
+
): { width: number; height: number } {
|
|
120
|
+
if (width <= maxWidth && height <= maxHeight) {
|
|
121
|
+
return { width, height };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
|
125
|
+
return {
|
|
126
|
+
width: Math.round(width * ratio),
|
|
127
|
+
height: Math.round(height * ratio),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Process image file
|
|
133
|
+
*/
|
|
134
|
+
async function process(file: File, options: ProcessOptions = {}): Promise<Blob> {
|
|
135
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
136
|
+
|
|
137
|
+
// Read file buffer for EXIF
|
|
138
|
+
const buffer = await file.arrayBuffer();
|
|
139
|
+
const orientation = readExifOrientation(buffer);
|
|
140
|
+
const transform = ORIENTATIONS[orientation] || ORIENTATIONS[1];
|
|
141
|
+
|
|
142
|
+
// Load image
|
|
143
|
+
const img = await loadImage(file);
|
|
144
|
+
|
|
145
|
+
// For 90° or 270° rotation, swap dimensions
|
|
146
|
+
const isRotated = transform.rotate === 90 || transform.rotate === 270;
|
|
147
|
+
const srcWidth = isRotated ? img.height : img.width;
|
|
148
|
+
const srcHeight = isRotated ? img.width : img.height;
|
|
149
|
+
|
|
150
|
+
// Calculate output size
|
|
151
|
+
const { width, height } = calculateDimensions(
|
|
152
|
+
srcWidth,
|
|
153
|
+
srcHeight,
|
|
154
|
+
opts.maxWidth,
|
|
155
|
+
opts.maxHeight
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Create canvas
|
|
159
|
+
const canvas = document.createElement("canvas");
|
|
160
|
+
canvas.width = width;
|
|
161
|
+
canvas.height = height;
|
|
162
|
+
|
|
163
|
+
const ctx = canvas.getContext("2d");
|
|
164
|
+
if (!ctx) throw new Error("Failed to get canvas context");
|
|
165
|
+
|
|
166
|
+
// Apply transformations
|
|
167
|
+
ctx.save();
|
|
168
|
+
ctx.translate(width / 2, height / 2);
|
|
169
|
+
|
|
170
|
+
if (transform.rotate) {
|
|
171
|
+
ctx.rotate((transform.rotate * Math.PI) / 180);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (transform.flip) {
|
|
175
|
+
ctx.scale(-1, 1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const drawWidth = isRotated ? height : width;
|
|
179
|
+
const drawHeight = isRotated ? width : height;
|
|
180
|
+
ctx.drawImage(img, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
|
|
181
|
+
|
|
182
|
+
ctx.restore();
|
|
183
|
+
|
|
184
|
+
// Export as WebP
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
canvas.toBlob(
|
|
187
|
+
(blob) => {
|
|
188
|
+
if (blob) {
|
|
189
|
+
resolve(blob);
|
|
190
|
+
} else {
|
|
191
|
+
reject(new Error("Failed to create blob"));
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
opts.mimeType,
|
|
195
|
+
opts.quality
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Process file and create a new File object
|
|
202
|
+
*/
|
|
203
|
+
async function processToFile(file: File, options: ProcessOptions = {}): Promise<File> {
|
|
204
|
+
const blob = await process(file, options);
|
|
205
|
+
|
|
206
|
+
// Generate new filename with .webp extension
|
|
207
|
+
const originalName = file.name.replace(/\.[^.]+$/, "");
|
|
208
|
+
const newName = `${originalName}.webp`;
|
|
209
|
+
|
|
210
|
+
return new File([blob], newName, { type: "image/webp" });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const ImageProcessor = { process, processToFile };
|
|
214
|
+
|
|
215
|
+
// Expose globally for inline scripts
|
|
216
|
+
if (typeof window !== "undefined") {
|
|
217
|
+
(window as unknown as { ImageProcessor: typeof ImageProcessor }).ImageProcessor = ImageProcessor;
|
|
218
|
+
}
|
package/src/preset.css
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Jant Core Preset
|
|
3
3
|
*
|
|
4
|
-
* Includes basecoat UI and
|
|
5
|
-
*
|
|
4
|
+
* Includes basecoat UI, component styles, and source scanning for @jant/core components.
|
|
5
|
+
* This file should be imported in user's style.css after tailwindcss.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/* BaseCoat UI framework */
|
|
8
9
|
@import "basecoat-css";
|
|
10
|
+
|
|
11
|
+
/* Jant component styles */
|
|
9
12
|
@import "./styles/components.css";
|
|
10
13
|
|
|
14
|
+
/* Scan @jant/core source files for Tailwind classes */
|
|
15
|
+
/* These paths are relative to this CSS file's location */
|
|
16
|
+
@source "./theme/**/*.{ts,tsx}";
|
|
17
|
+
@source "./routes/**/*.{ts,tsx}";
|
|
18
|
+
|
|
11
19
|
@theme {
|
|
12
20
|
--radius-default: 0.5rem;
|
|
13
21
|
}
|
|
@@ -13,7 +13,6 @@ import { DashLayout } from "../../theme/layouts/index.js";
|
|
|
13
13
|
import { EmptyState, DangerZone } from "../../theme/components/index.js";
|
|
14
14
|
import * as time from "../../lib/time.js";
|
|
15
15
|
import { getMediaUrl, getImageUrl } from "../../lib/image.js";
|
|
16
|
-
import { getAssets } from "../../lib/assets.js";
|
|
17
16
|
|
|
18
17
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
19
18
|
|
|
@@ -96,12 +95,10 @@ function MediaListContent({
|
|
|
96
95
|
mediaList,
|
|
97
96
|
r2PublicUrl,
|
|
98
97
|
imageTransformUrl,
|
|
99
|
-
imageProcessorUrl,
|
|
100
98
|
}: {
|
|
101
99
|
mediaList: Media[];
|
|
102
100
|
r2PublicUrl?: string;
|
|
103
101
|
imageTransformUrl?: string;
|
|
104
|
-
imageProcessorUrl: string;
|
|
105
102
|
}) {
|
|
106
103
|
const { t } = useLingui();
|
|
107
104
|
|
|
@@ -265,8 +262,7 @@ function processSSEEvent(event) {
|
|
|
265
262
|
|
|
266
263
|
return (
|
|
267
264
|
<>
|
|
268
|
-
{/*
|
|
269
|
-
<script src={imageProcessorUrl}></script>
|
|
265
|
+
{/* Upload script */}
|
|
270
266
|
<script dangerouslySetInnerHTML={{ __html: uploadScript }}></script>
|
|
271
267
|
|
|
272
268
|
{/* Header */}
|
|
@@ -493,7 +489,6 @@ mediaRoutes.get("/", async (c) => {
|
|
|
493
489
|
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
494
490
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
495
491
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
496
|
-
const assets = getAssets();
|
|
497
492
|
|
|
498
493
|
return c.html(
|
|
499
494
|
<DashLayout c={c} title="Media" siteName={siteName} currentPath="/dash/media">
|
|
@@ -501,7 +496,6 @@ mediaRoutes.get("/", async (c) => {
|
|
|
501
496
|
mediaList={mediaList}
|
|
502
497
|
r2PublicUrl={r2PublicUrl}
|
|
503
498
|
imageTransformUrl={imageTransformUrl}
|
|
504
|
-
imageProcessorUrl={assets.imageProcessor}
|
|
505
499
|
/>
|
|
506
500
|
</DashLayout>
|
|
507
501
|
);
|