@realiizlabs/admin 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ "use client";
2
+ 'use strict';
3
+
4
+ // src/media/errors.ts
5
+ var MediaError = class extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "MediaError";
9
+ }
10
+ };
11
+ var MESSAGES = {
12
+ type: "That file type isn\u2019t supported \u2014 use a JPG, PNG or WebP.",
13
+ size: "That picture is too big to send \u2014 pick one under 10MB.",
14
+ encode: "This browser can\u2019t prepare pictures \u2014 try Chrome, Edge or Safari 16+."
15
+ };
16
+
17
+ // src/media/resize.ts
18
+ var ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
19
+ var MAX_INPUT_BYTES = 10 * 1024 * 1024;
20
+ function fitWithin(width, height, maxWidth) {
21
+ if (width <= maxWidth) return { width, height };
22
+ const ratio = maxWidth / width;
23
+ return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };
24
+ }
25
+ function checkInput(file) {
26
+ if (!ACCEPTED_TYPES.includes(file.type)) throw new MediaError(MESSAGES.type);
27
+ if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);
28
+ }
29
+ async function resizeImage(file, opts = {}) {
30
+ checkInput(file);
31
+ const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;
32
+ const bitmap = await decode(file);
33
+ const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);
34
+ const canvas = makeCanvas(width, height);
35
+ const ctx = canvas.getContext("2d");
36
+ if (!ctx) throw new MediaError(MESSAGES.encode);
37
+ ctx.drawImage(bitmap, 0, 0, width, height);
38
+ bitmap.close?.();
39
+ let q = quality;
40
+ let blob = await encode(canvas, q);
41
+ while (blob.size > maxBytes && q - 0.08 >= 0.5) {
42
+ q -= 0.08;
43
+ blob = await encode(canvas, q);
44
+ }
45
+ return { blob, width, height };
46
+ }
47
+ async function decode(file) {
48
+ try {
49
+ return await createImageBitmap(file);
50
+ } catch {
51
+ throw new MediaError(MESSAGES.encode);
52
+ }
53
+ }
54
+ function makeCanvas(width, height) {
55
+ if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
56
+ const c = document.createElement("canvas");
57
+ c.width = width;
58
+ c.height = height;
59
+ return c;
60
+ }
61
+ async function encode(canvas, quality) {
62
+ const blob = "convertToBlob" in canvas ? await canvas.convertToBlob({ type: "image/webp", quality }).catch(() => null) : await new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
63
+ if (!blob || blob.type !== "image/webp") throw new MediaError(MESSAGES.encode);
64
+ return blob;
65
+ }
66
+
67
+ // src/media/paths.ts
68
+ function slugifyName(name) {
69
+ const base = name.replace(/\.[a-z0-9]+$/i, "");
70
+ const s = base.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
71
+ return s || "image";
72
+ }
73
+ function imagePathFor(kind, slug, originalName = "", opts = {}) {
74
+ const dir = (opts.publicDir ?? "/blog").replace(/\/$/, "");
75
+ switch (kind) {
76
+ case "hero":
77
+ return `${dir}/${slug}.webp`;
78
+ case "social":
79
+ return `${dir}/${slug}-social.webp`;
80
+ case "body":
81
+ return `${dir}/${slug}/${slugifyName(originalName)}.webp`;
82
+ }
83
+ }
84
+
85
+ exports.ACCEPTED_TYPES = ACCEPTED_TYPES;
86
+ exports.MAX_INPUT_BYTES = MAX_INPUT_BYTES;
87
+ exports.MEDIA_MESSAGES = MESSAGES;
88
+ exports.MediaError = MediaError;
89
+ exports.checkInput = checkInput;
90
+ exports.fitWithin = fitWithin;
91
+ exports.imagePathFor = imagePathFor;
92
+ exports.resizeImage = resizeImage;
93
+ exports.slugifyName = slugifyName;
94
+ //# sourceMappingURL=index.cjs.map
95
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/media/errors.ts","../../src/media/resize.ts","../../src/media/paths.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/** A picture problem the person can act on. The message is the sentence shown under the control. */\nexport class MediaError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MediaError\";\n }\n}\n\nexport const MESSAGES = {\n type: \"That file type isn’t supported — use a JPG, PNG or WebP.\",\n size: \"That picture is too big to send — pick one under 10MB.\",\n encode: \"This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.\",\n} as const;\n","/**\n * resizeImage — shrink a picture in the browser to web size, as WebP.\n *\n * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the\n * GitHub API wants base64. So the shrink happens here, before anything leaves the\n * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering\n * quality in steps until the result is under maxBytes. GIFs lose their animation\n * (a still frame is encoded) — acceptable for a blog.\n *\n * No network, no dependencies. Throws MediaError with a plain sentence for the\n * three things that can go wrong: wrong type, too big to even try, or a browser\n * that cannot encode WebP (toBlob returns null or a non-WebP type).\n */\n\nimport { MediaError, MESSAGES } from \"./errors\";\n\nexport const ACCEPTED_TYPES = [\"image/jpeg\", \"image/png\", \"image/webp\", \"image/gif\"] as const;\nexport const MAX_INPUT_BYTES = 10 * 1024 * 1024;\n\nexport interface ResizeOptions {\n /** Longest allowed width in pixels. Default 1600. */\n maxWidth?: number;\n /** Starting WebP quality 0–1. Default 0.82. */\n quality?: number;\n /** Target ceiling for the encoded size. Default 600KB. */\n maxBytes?: number;\n}\n\nexport interface ResizedImage {\n blob: Blob;\n width: number;\n height: number;\n}\n\n/** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */\nexport function fitWithin(width: number, height: number, maxWidth: number): { width: number; height: number } {\n if (width <= maxWidth) return { width, height };\n const ratio = maxWidth / width;\n return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };\n}\n\n/** The type/size guards, separated so they can run (and be tested) without a canvas. */\nexport function checkInput(file: { type: string; size: number }): void {\n if (!(ACCEPTED_TYPES as readonly string[]).includes(file.type)) throw new MediaError(MESSAGES.type);\n if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);\n}\n\nexport async function resizeImage(file: File, opts: ResizeOptions = {}): Promise<ResizedImage> {\n checkInput(file);\n const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;\n\n const bitmap = await decode(file);\n const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);\n const canvas = makeCanvas(width, height);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) throw new MediaError(MESSAGES.encode);\n ctx.drawImage(bitmap, 0, 0, width, height);\n bitmap.close?.();\n\n let q = quality;\n let blob = await encode(canvas, q);\n while (blob.size > maxBytes && q - 0.08 >= 0.5) {\n q -= 0.08;\n blob = await encode(canvas, q);\n }\n return { blob, width, height };\n}\n\nasync function decode(file: File): Promise<ImageBitmap> {\n try {\n return await createImageBitmap(file);\n } catch {\n throw new MediaError(MESSAGES.encode);\n }\n}\n\ntype AnyCanvas = HTMLCanvasElement | OffscreenCanvas;\n\nfunction makeCanvas(width: number, height: number): AnyCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") return new OffscreenCanvas(width, height);\n const c = document.createElement(\"canvas\");\n c.width = width;\n c.height = height;\n return c;\n}\n\nasync function encode(canvas: AnyCanvas, quality: number): Promise<Blob> {\n const blob =\n \"convertToBlob\" in canvas\n ? await canvas.convertToBlob({ type: \"image/webp\", quality }).catch(() => null)\n : await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, \"image/webp\", quality));\n if (!blob || blob.type !== \"image/webp\") throw new MediaError(MESSAGES.encode);\n return blob;\n}\n","/**\n * Where a picture lives, decided from the post's slug so the path is known before\n * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,\n * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.\n */\n\nexport type ImageKind = \"hero\" | \"social\" | \"body\";\n\nexport interface PathOptions {\n /** URL prefix the pictures are served from. Default \"/blog\". */\n publicDir?: string;\n}\n\n/** Lower-case letters, digits and single hyphens; empty in → \"image\". */\nexport function slugifyName(name: string): string {\n const base = name.replace(/\\.[a-z0-9]+$/i, \"\");\n const s = base\n .normalize(\"NFKD\")\n .replace(/[̀-ͯ]/g, \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"image\";\n}\n\nexport function imagePathFor(kind: ImageKind, slug: string, originalName = \"\", opts: PathOptions = {}): string {\n const dir = (opts.publicDir ?? \"/blog\").replace(/\\/$/, \"\");\n switch (kind) {\n case \"hero\":\n return `${dir}/${slug}.webp`;\n case \"social\":\n return `${dir}/${slug}-social.webp`;\n case \"body\":\n return `${dir}/${slug}/${slugifyName(originalName)}.webp`;\n }\n}\n"]}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * resizeImage — shrink a picture in the browser to web size, as WebP.
3
+ *
4
+ * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the
5
+ * GitHub API wants base64. So the shrink happens here, before anything leaves the
6
+ * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering
7
+ * quality in steps until the result is under maxBytes. GIFs lose their animation
8
+ * (a still frame is encoded) — acceptable for a blog.
9
+ *
10
+ * No network, no dependencies. Throws MediaError with a plain sentence for the
11
+ * three things that can go wrong: wrong type, too big to even try, or a browser
12
+ * that cannot encode WebP (toBlob returns null or a non-WebP type).
13
+ */
14
+ declare const ACCEPTED_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/gif"];
15
+ declare const MAX_INPUT_BYTES: number;
16
+ interface ResizeOptions {
17
+ /** Longest allowed width in pixels. Default 1600. */
18
+ maxWidth?: number;
19
+ /** Starting WebP quality 0–1. Default 0.82. */
20
+ quality?: number;
21
+ /** Target ceiling for the encoded size. Default 600KB. */
22
+ maxBytes?: number;
23
+ }
24
+ interface ResizedImage {
25
+ blob: Blob;
26
+ width: number;
27
+ height: number;
28
+ }
29
+ /** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */
30
+ declare function fitWithin(width: number, height: number, maxWidth: number): {
31
+ width: number;
32
+ height: number;
33
+ };
34
+ /** The type/size guards, separated so they can run (and be tested) without a canvas. */
35
+ declare function checkInput(file: {
36
+ type: string;
37
+ size: number;
38
+ }): void;
39
+ declare function resizeImage(file: File, opts?: ResizeOptions): Promise<ResizedImage>;
40
+
41
+ /**
42
+ * Where a picture lives, decided from the post's slug so the path is known before
43
+ * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,
44
+ * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.
45
+ */
46
+ type ImageKind = "hero" | "social" | "body";
47
+ interface PathOptions {
48
+ /** URL prefix the pictures are served from. Default "/blog". */
49
+ publicDir?: string;
50
+ }
51
+ /** Lower-case letters, digits and single hyphens; empty in → "image". */
52
+ declare function slugifyName(name: string): string;
53
+ declare function imagePathFor(kind: ImageKind, slug: string, originalName?: string, opts?: PathOptions): string;
54
+
55
+ /** A picture problem the person can act on. The message is the sentence shown under the control. */
56
+ declare class MediaError extends Error {
57
+ constructor(message: string);
58
+ }
59
+ declare const MESSAGES: {
60
+ readonly type: "That file type isn’t supported — use a JPG, PNG or WebP.";
61
+ readonly size: "That picture is too big to send — pick one under 10MB.";
62
+ readonly encode: "This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.";
63
+ };
64
+
65
+ export { ACCEPTED_TYPES, type ImageKind, MAX_INPUT_BYTES, MESSAGES as MEDIA_MESSAGES, MediaError, type PathOptions, type ResizeOptions, type ResizedImage, checkInput, fitWithin, imagePathFor, resizeImage, slugifyName };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * resizeImage — shrink a picture in the browser to web size, as WebP.
3
+ *
4
+ * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the
5
+ * GitHub API wants base64. So the shrink happens here, before anything leaves the
6
+ * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering
7
+ * quality in steps until the result is under maxBytes. GIFs lose their animation
8
+ * (a still frame is encoded) — acceptable for a blog.
9
+ *
10
+ * No network, no dependencies. Throws MediaError with a plain sentence for the
11
+ * three things that can go wrong: wrong type, too big to even try, or a browser
12
+ * that cannot encode WebP (toBlob returns null or a non-WebP type).
13
+ */
14
+ declare const ACCEPTED_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/gif"];
15
+ declare const MAX_INPUT_BYTES: number;
16
+ interface ResizeOptions {
17
+ /** Longest allowed width in pixels. Default 1600. */
18
+ maxWidth?: number;
19
+ /** Starting WebP quality 0–1. Default 0.82. */
20
+ quality?: number;
21
+ /** Target ceiling for the encoded size. Default 600KB. */
22
+ maxBytes?: number;
23
+ }
24
+ interface ResizedImage {
25
+ blob: Blob;
26
+ width: number;
27
+ height: number;
28
+ }
29
+ /** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */
30
+ declare function fitWithin(width: number, height: number, maxWidth: number): {
31
+ width: number;
32
+ height: number;
33
+ };
34
+ /** The type/size guards, separated so they can run (and be tested) without a canvas. */
35
+ declare function checkInput(file: {
36
+ type: string;
37
+ size: number;
38
+ }): void;
39
+ declare function resizeImage(file: File, opts?: ResizeOptions): Promise<ResizedImage>;
40
+
41
+ /**
42
+ * Where a picture lives, decided from the post's slug so the path is known before
43
+ * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,
44
+ * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.
45
+ */
46
+ type ImageKind = "hero" | "social" | "body";
47
+ interface PathOptions {
48
+ /** URL prefix the pictures are served from. Default "/blog". */
49
+ publicDir?: string;
50
+ }
51
+ /** Lower-case letters, digits and single hyphens; empty in → "image". */
52
+ declare function slugifyName(name: string): string;
53
+ declare function imagePathFor(kind: ImageKind, slug: string, originalName?: string, opts?: PathOptions): string;
54
+
55
+ /** A picture problem the person can act on. The message is the sentence shown under the control. */
56
+ declare class MediaError extends Error {
57
+ constructor(message: string);
58
+ }
59
+ declare const MESSAGES: {
60
+ readonly type: "That file type isn’t supported — use a JPG, PNG or WebP.";
61
+ readonly size: "That picture is too big to send — pick one under 10MB.";
62
+ readonly encode: "This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.";
63
+ };
64
+
65
+ export { ACCEPTED_TYPES, type ImageKind, MAX_INPUT_BYTES, MESSAGES as MEDIA_MESSAGES, MediaError, type PathOptions, type ResizeOptions, type ResizedImage, checkInput, fitWithin, imagePathFor, resizeImage, slugifyName };
@@ -0,0 +1,85 @@
1
+ "use client";
2
+ // src/media/errors.ts
3
+ var MediaError = class extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "MediaError";
7
+ }
8
+ };
9
+ var MESSAGES = {
10
+ type: "That file type isn\u2019t supported \u2014 use a JPG, PNG or WebP.",
11
+ size: "That picture is too big to send \u2014 pick one under 10MB.",
12
+ encode: "This browser can\u2019t prepare pictures \u2014 try Chrome, Edge or Safari 16+."
13
+ };
14
+
15
+ // src/media/resize.ts
16
+ var ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
17
+ var MAX_INPUT_BYTES = 10 * 1024 * 1024;
18
+ function fitWithin(width, height, maxWidth) {
19
+ if (width <= maxWidth) return { width, height };
20
+ const ratio = maxWidth / width;
21
+ return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };
22
+ }
23
+ function checkInput(file) {
24
+ if (!ACCEPTED_TYPES.includes(file.type)) throw new MediaError(MESSAGES.type);
25
+ if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);
26
+ }
27
+ async function resizeImage(file, opts = {}) {
28
+ checkInput(file);
29
+ const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;
30
+ const bitmap = await decode(file);
31
+ const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);
32
+ const canvas = makeCanvas(width, height);
33
+ const ctx = canvas.getContext("2d");
34
+ if (!ctx) throw new MediaError(MESSAGES.encode);
35
+ ctx.drawImage(bitmap, 0, 0, width, height);
36
+ bitmap.close?.();
37
+ let q = quality;
38
+ let blob = await encode(canvas, q);
39
+ while (blob.size > maxBytes && q - 0.08 >= 0.5) {
40
+ q -= 0.08;
41
+ blob = await encode(canvas, q);
42
+ }
43
+ return { blob, width, height };
44
+ }
45
+ async function decode(file) {
46
+ try {
47
+ return await createImageBitmap(file);
48
+ } catch {
49
+ throw new MediaError(MESSAGES.encode);
50
+ }
51
+ }
52
+ function makeCanvas(width, height) {
53
+ if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
54
+ const c = document.createElement("canvas");
55
+ c.width = width;
56
+ c.height = height;
57
+ return c;
58
+ }
59
+ async function encode(canvas, quality) {
60
+ const blob = "convertToBlob" in canvas ? await canvas.convertToBlob({ type: "image/webp", quality }).catch(() => null) : await new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
61
+ if (!blob || blob.type !== "image/webp") throw new MediaError(MESSAGES.encode);
62
+ return blob;
63
+ }
64
+
65
+ // src/media/paths.ts
66
+ function slugifyName(name) {
67
+ const base = name.replace(/\.[a-z0-9]+$/i, "");
68
+ const s = base.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
69
+ return s || "image";
70
+ }
71
+ function imagePathFor(kind, slug, originalName = "", opts = {}) {
72
+ const dir = (opts.publicDir ?? "/blog").replace(/\/$/, "");
73
+ switch (kind) {
74
+ case "hero":
75
+ return `${dir}/${slug}.webp`;
76
+ case "social":
77
+ return `${dir}/${slug}-social.webp`;
78
+ case "body":
79
+ return `${dir}/${slug}/${slugifyName(originalName)}.webp`;
80
+ }
81
+ }
82
+
83
+ export { ACCEPTED_TYPES, MAX_INPUT_BYTES, MESSAGES as MEDIA_MESSAGES, MediaError, checkInput, fitWithin, imagePathFor, resizeImage, slugifyName };
84
+ //# sourceMappingURL=index.js.map
85
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/media/errors.ts","../../src/media/resize.ts","../../src/media/paths.ts"],"names":[],"mappings":";AACO,IAAM,UAAA,GAAN,cAAyB,KAAA,CAAM;AAAA,EACpC,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,YAAA;AAAA,EACd;AACF;AAEO,IAAM,QAAA,GAAW;AAAA,EACtB,IAAA,EAAM,oEAAA;AAAA,EACN,IAAA,EAAM,6DAAA;AAAA,EACN,MAAA,EAAQ;AACV;;;ACIO,IAAM,cAAA,GAAiB,CAAC,YAAA,EAAc,WAAA,EAAa,cAAc,WAAW;AAC5E,IAAM,eAAA,GAAkB,KAAK,IAAA,GAAO;AAkBpC,SAAS,SAAA,CAAU,KAAA,EAAe,MAAA,EAAgB,QAAA,EAAqD;AAC5G,EAAA,IAAI,KAAA,IAAS,QAAA,EAAU,OAAO,EAAE,OAAO,MAAA,EAAO;AAC9C,EAAA,MAAM,QAAQ,QAAA,GAAW,KAAA;AACzB,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,CAAK,KAAA,CAAM,KAAA,GAAQ,KAAK,CAAA,EAAG,MAAA,EAAQ,IAAA,CAAK,GAAA,CAAI,GAAG,IAAA,CAAK,KAAA,CAAM,MAAA,GAAS,KAAK,CAAC,CAAA,EAAE;AAC7F;AAGO,SAAS,WAAW,IAAA,EAA4C;AACrE,EAAA,IAAI,CAAE,cAAA,CAAqC,QAAA,CAAS,IAAA,CAAK,IAAI,GAAG,MAAM,IAAI,UAAA,CAAW,QAAA,CAAS,IAAI,CAAA;AAClG,EAAA,IAAI,KAAK,IAAA,GAAO,eAAA,QAAuB,IAAI,UAAA,CAAW,SAAS,IAAI,CAAA;AACrE;AAEA,eAAsB,WAAA,CAAY,IAAA,EAAY,IAAA,GAAsB,EAAC,EAA0B;AAC7F,EAAA,UAAA,CAAW,IAAI,CAAA;AACf,EAAA,MAAM,EAAE,WAAW,IAAA,EAAM,OAAA,GAAU,MAAM,QAAA,GAAW,GAAA,GAAM,MAAK,GAAI,IAAA;AAEnE,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,IAAI,CAAA;AAChC,EAAA,MAAM,EAAE,OAAO,MAAA,EAAO,GAAI,UAAU,MAAA,CAAO,KAAA,EAAO,MAAA,CAAO,MAAA,EAAQ,QAAQ,CAAA;AACzE,EAAA,MAAM,MAAA,GAAS,UAAA,CAAW,KAAA,EAAO,MAAM,CAAA;AACvC,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,UAAA,CAAW,IAAI,CAAA;AAClC,EAAA,IAAI,CAAC,GAAA,EAAK,MAAM,IAAI,UAAA,CAAW,SAAS,MAAM,CAAA;AAC9C,EAAA,GAAA,CAAI,SAAA,CAAU,MAAA,EAAQ,CAAA,EAAG,CAAA,EAAG,OAAO,MAAM,CAAA;AACzC,EAAA,MAAA,CAAO,KAAA,IAAQ;AAEf,EAAA,IAAI,CAAA,GAAI,OAAA;AACR,EAAA,IAAI,IAAA,GAAO,MAAM,MAAA,CAAO,MAAA,EAAQ,CAAC,CAAA;AACjC,EAAA,OAAO,IAAA,CAAK,IAAA,GAAO,QAAA,IAAY,CAAA,GAAI,QAAQ,GAAA,EAAK;AAC9C,IAAA,CAAA,IAAK,IAAA;AACL,IAAA,IAAA,GAAO,MAAM,MAAA,CAAO,MAAA,EAAQ,CAAC,CAAA;AAAA,EAC/B;AACA,EAAA,OAAO,EAAE,IAAA,EAAM,KAAA,EAAO,MAAA,EAAO;AAC/B;AAEA,eAAe,OAAO,IAAA,EAAkC;AACtD,EAAA,IAAI;AACF,IAAA,OAAO,MAAM,kBAAkB,IAAI,CAAA;AAAA,EACrC,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,UAAA,CAAW,QAAA,CAAS,MAAM,CAAA;AAAA,EACtC;AACF;AAIA,SAAS,UAAA,CAAW,OAAe,MAAA,EAA2B;AAC5D,EAAA,IAAI,OAAO,eAAA,KAAoB,WAAA,SAAoB,IAAI,eAAA,CAAgB,OAAO,MAAM,CAAA;AACpF,EAAA,MAAM,CAAA,GAAI,QAAA,CAAS,aAAA,CAAc,QAAQ,CAAA;AACzC,EAAA,CAAA,CAAE,KAAA,GAAQ,KAAA;AACV,EAAA,CAAA,CAAE,MAAA,GAAS,MAAA;AACX,EAAA,OAAO,CAAA;AACT;AAEA,eAAe,MAAA,CAAO,QAAmB,OAAA,EAAgC;AACvE,EAAA,MAAM,IAAA,GACJ,eAAA,IAAmB,MAAA,GACf,MAAM,MAAA,CAAO,aAAA,CAAc,EAAE,IAAA,EAAM,YAAA,EAAc,OAAA,EAAS,CAAA,CAAE,KAAA,CAAM,MAAM,IAAI,CAAA,GAC5E,MAAM,IAAI,OAAA,CAAqB,CAAC,OAAA,KAAY,MAAA,CAAO,MAAA,CAAO,OAAA,EAAS,YAAA,EAAc,OAAO,CAAC,CAAA;AAC/F,EAAA,IAAI,CAAC,QAAQ,IAAA,CAAK,IAAA,KAAS,cAAc,MAAM,IAAI,UAAA,CAAW,QAAA,CAAS,MAAM,CAAA;AAC7E,EAAA,OAAO,IAAA;AACT;;;AC/EO,SAAS,YAAY,IAAA,EAAsB;AAChD,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,OAAA,CAAQ,eAAA,EAAiB,EAAE,CAAA;AAC7C,EAAA,MAAM,IAAI,IAAA,CACP,SAAA,CAAU,MAAM,CAAA,CAChB,QAAQ,QAAA,EAAU,EAAE,CAAA,CACpB,WAAA,GACA,OAAA,CAAQ,aAAA,EAAe,GAAG,CAAA,CAC1B,OAAA,CAAQ,YAAY,EAAE,CAAA;AACzB,EAAA,OAAO,CAAA,IAAK,OAAA;AACd;AAEO,SAAS,aAAa,IAAA,EAAiB,IAAA,EAAc,eAAe,EAAA,EAAI,IAAA,GAAoB,EAAC,EAAW;AAC7G,EAAA,MAAM,OAAO,IAAA,CAAK,SAAA,IAAa,OAAA,EAAS,OAAA,CAAQ,OAAO,EAAE,CAAA;AACzD,EAAA,QAAQ,IAAA;AAAM,IACZ,KAAK,MAAA;AACH,MAAA,OAAO,CAAA,EAAG,GAAG,CAAA,CAAA,EAAI,IAAI,CAAA,KAAA,CAAA;AAAA,IACvB,KAAK,QAAA;AACH,MAAA,OAAO,CAAA,EAAG,GAAG,CAAA,CAAA,EAAI,IAAI,CAAA,YAAA,CAAA;AAAA,IACvB,KAAK,MAAA;AACH,MAAA,OAAO,GAAG,GAAG,CAAA,CAAA,EAAI,IAAI,CAAA,CAAA,EAAI,WAAA,CAAY,YAAY,CAAC,CAAA,KAAA,CAAA;AAAA;AAExD","file":"index.js","sourcesContent":["/** A picture problem the person can act on. The message is the sentence shown under the control. */\nexport class MediaError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MediaError\";\n }\n}\n\nexport const MESSAGES = {\n type: \"That file type isn’t supported — use a JPG, PNG or WebP.\",\n size: \"That picture is too big to send — pick one under 10MB.\",\n encode: \"This browser can’t prepare pictures — try Chrome, Edge or Safari 16+.\",\n} as const;\n","/**\n * resizeImage — shrink a picture in the browser to web size, as WebP.\n *\n * Phone photos are 3–8MB; a Vercel server action body is capped at 4.5MB and the\n * GitHub API wants base64. So the shrink happens here, before anything leaves the\n * browser: decode → fit inside maxWidth → draw to a canvas → encode WebP, lowering\n * quality in steps until the result is under maxBytes. GIFs lose their animation\n * (a still frame is encoded) — acceptable for a blog.\n *\n * No network, no dependencies. Throws MediaError with a plain sentence for the\n * three things that can go wrong: wrong type, too big to even try, or a browser\n * that cannot encode WebP (toBlob returns null or a non-WebP type).\n */\n\nimport { MediaError, MESSAGES } from \"./errors\";\n\nexport const ACCEPTED_TYPES = [\"image/jpeg\", \"image/png\", \"image/webp\", \"image/gif\"] as const;\nexport const MAX_INPUT_BYTES = 10 * 1024 * 1024;\n\nexport interface ResizeOptions {\n /** Longest allowed width in pixels. Default 1600. */\n maxWidth?: number;\n /** Starting WebP quality 0–1. Default 0.82. */\n quality?: number;\n /** Target ceiling for the encoded size. Default 600KB. */\n maxBytes?: number;\n}\n\nexport interface ResizedImage {\n blob: Blob;\n width: number;\n height: number;\n}\n\n/** Pure: fit (width × height) inside maxWidth, never scaling up, whole pixels. */\nexport function fitWithin(width: number, height: number, maxWidth: number): { width: number; height: number } {\n if (width <= maxWidth) return { width, height };\n const ratio = maxWidth / width;\n return { width: Math.round(width * ratio), height: Math.max(1, Math.round(height * ratio)) };\n}\n\n/** The type/size guards, separated so they can run (and be tested) without a canvas. */\nexport function checkInput(file: { type: string; size: number }): void {\n if (!(ACCEPTED_TYPES as readonly string[]).includes(file.type)) throw new MediaError(MESSAGES.type);\n if (file.size > MAX_INPUT_BYTES) throw new MediaError(MESSAGES.size);\n}\n\nexport async function resizeImage(file: File, opts: ResizeOptions = {}): Promise<ResizedImage> {\n checkInput(file);\n const { maxWidth = 1600, quality = 0.82, maxBytes = 600 * 1024 } = opts;\n\n const bitmap = await decode(file);\n const { width, height } = fitWithin(bitmap.width, bitmap.height, maxWidth);\n const canvas = makeCanvas(width, height);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) throw new MediaError(MESSAGES.encode);\n ctx.drawImage(bitmap, 0, 0, width, height);\n bitmap.close?.();\n\n let q = quality;\n let blob = await encode(canvas, q);\n while (blob.size > maxBytes && q - 0.08 >= 0.5) {\n q -= 0.08;\n blob = await encode(canvas, q);\n }\n return { blob, width, height };\n}\n\nasync function decode(file: File): Promise<ImageBitmap> {\n try {\n return await createImageBitmap(file);\n } catch {\n throw new MediaError(MESSAGES.encode);\n }\n}\n\ntype AnyCanvas = HTMLCanvasElement | OffscreenCanvas;\n\nfunction makeCanvas(width: number, height: number): AnyCanvas {\n if (typeof OffscreenCanvas !== \"undefined\") return new OffscreenCanvas(width, height);\n const c = document.createElement(\"canvas\");\n c.width = width;\n c.height = height;\n return c;\n}\n\nasync function encode(canvas: AnyCanvas, quality: number): Promise<Blob> {\n const blob =\n \"convertToBlob\" in canvas\n ? await canvas.convertToBlob({ type: \"image/webp\", quality }).catch(() => null)\n : await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, \"image/webp\", quality));\n if (!blob || blob.type !== \"image/webp\") throw new MediaError(MESSAGES.encode);\n return blob;\n}\n","/**\n * Where a picture lives, decided from the post's slug so the path is known before\n * anything is committed: hero → /blog/{slug}.webp, social → /blog/{slug}-social.webp,\n * body → /blog/{slug}/{name}.webp. The host commits the file under public/ + path.\n */\n\nexport type ImageKind = \"hero\" | \"social\" | \"body\";\n\nexport interface PathOptions {\n /** URL prefix the pictures are served from. Default \"/blog\". */\n publicDir?: string;\n}\n\n/** Lower-case letters, digits and single hyphens; empty in → \"image\". */\nexport function slugifyName(name: string): string {\n const base = name.replace(/\\.[a-z0-9]+$/i, \"\");\n const s = base\n .normalize(\"NFKD\")\n .replace(/[̀-ͯ]/g, \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"image\";\n}\n\nexport function imagePathFor(kind: ImageKind, slug: string, originalName = \"\", opts: PathOptions = {}): string {\n const dir = (opts.publicDir ?? \"/blog\").replace(/\\/$/, \"\");\n switch (kind) {\n case \"hero\":\n return `${dir}/${slug}.webp`;\n case \"social\":\n return `${dir}/${slug}-social.webp`;\n case \"body\":\n return `${dir}/${slug}/${slugifyName(originalName)}.webp`;\n }\n}\n"]}
@@ -51,8 +51,12 @@ function Icon({ name }) {
51
51
  ] });
52
52
  case "settings":
53
53
  return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...base, children: [
54
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "3" }),
55
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" })
54
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 7h9" }),
55
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "16", cy: "7", r: "2" }),
56
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M19 7h1" }),
57
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 17h9" }),
58
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "16", cy: "17", r: "2" }),
59
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M19 17h1" })
56
60
  ] });
57
61
  default:
58
62
  return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...base, children: [
@@ -70,7 +74,7 @@ function isActive(item, activeHref, basePath) {
70
74
  if (item.href === basePath) return activeHref === basePath || activeHref === `${basePath}/`;
71
75
  return activeHref === item.href || activeHref.startsWith(`${item.href}/`);
72
76
  }
73
- function Sidebar({ items, activeHref, basePath }) {
77
+ function Sidebar({ items, pinned = [], activeHref, basePath }) {
74
78
  const [collapsed, setCollapsed] = react.useState(false);
75
79
  react.useEffect(() => {
76
80
  try {
@@ -86,30 +90,32 @@ function Sidebar({ items, activeHref, basePath }) {
86
90
  } catch {
87
91
  }
88
92
  };
89
- return /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: ["rz-sidebar", collapsed && "rz-sidebar--collapsed"].filter(Boolean).join(" "), "aria-label": "Admin navigation", children: [
90
- /* @__PURE__ */ jsxRuntime.jsx("nav", { className: "rz-sidebar__nav", children: items.map((item) => {
91
- const active = !item.disabled && isActive(item, activeHref, basePath);
92
- if (item.disabled) {
93
- return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "rz-sidebar__item rz-sidebar__item--disabled", "aria-disabled": "true", title: collapsed ? `${item.label} \u2014 coming soon` : "Coming soon", children: [
93
+ const renderItem = (item) => {
94
+ const active = !item.disabled && isActive(item, activeHref, basePath);
95
+ if (item.disabled) {
96
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "rz-sidebar__item rz-sidebar__item--disabled", "aria-disabled": "true", title: collapsed ? `${item.label} \u2014 coming soon` : "Coming soon", children: [
97
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: iconFor(item.icon ?? item.id) }),
98
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rz-sidebar__label", children: item.label })
99
+ ] }, item.id);
100
+ }
101
+ return /* @__PURE__ */ jsxRuntime.jsxs(
102
+ "a",
103
+ {
104
+ href: item.href,
105
+ className: ["rz-sidebar__item", active && "rz-sidebar__item--active"].filter(Boolean).join(" "),
106
+ "aria-current": active ? "page" : void 0,
107
+ title: collapsed ? item.label : void 0,
108
+ children: [
94
109
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: iconFor(item.icon ?? item.id) }),
95
110
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rz-sidebar__label", children: item.label })
96
- ] }, item.id);
97
- }
98
- return /* @__PURE__ */ jsxRuntime.jsxs(
99
- "a",
100
- {
101
- href: item.href,
102
- className: ["rz-sidebar__item", active && "rz-sidebar__item--active"].filter(Boolean).join(" "),
103
- "aria-current": active ? "page" : void 0,
104
- title: collapsed ? item.label : void 0,
105
- children: [
106
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: iconFor(item.icon ?? item.id) }),
107
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rz-sidebar__label", children: item.label })
108
- ]
109
- },
110
- item.id
111
- );
112
- }) }),
111
+ ]
112
+ },
113
+ item.id
114
+ );
115
+ };
116
+ return /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: ["rz-sidebar", collapsed && "rz-sidebar--collapsed"].filter(Boolean).join(" "), "aria-label": "Admin navigation", children: [
117
+ /* @__PURE__ */ jsxRuntime.jsx("nav", { className: "rz-sidebar__nav", children: items.map(renderItem) }),
118
+ pinned.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("nav", { className: "rz-sidebar__pinned", "aria-label": "Settings", children: pinned.map(renderItem) }),
113
119
  /* @__PURE__ */ jsxRuntime.jsxs(
114
120
  "button",
115
121
  {
@@ -278,6 +284,8 @@ var SHELL_CSS = `
278
284
  .rz-admin a { color: inherit; }
279
285
  .rz-admin h1, .rz-admin h2, .rz-admin h3 { font-family: var(--rz-font-display); color: var(--text-primary); margin: 0; }
280
286
  .rz-admin .realiiz-form__control { background: var(--surface-0); }
287
+ .rz-admin .realiiz-image { background: var(--surface-0); }
288
+ .rz-admin .realiiz-image__thumb { background: var(--surface-2); }
281
289
  .rz-admin[data-theme="dark"] .realiiz-form__control::-webkit-calendar-picker-indicator { filter: invert(1) opacity(.7); }
282
290
  .rz-admin .realiiz-form__button { background: var(--surface-1); color: var(--text-primary); }
283
291
  .rz-admin .realiiz-form__button--primary { background: var(--signal); color: var(--signal-fg); }
@@ -301,6 +309,7 @@ var SHELL_CSS = `
301
309
  .rz-sidebar__item--disabled, .rz-sidebar__item--disabled:hover { color: var(--text-muted); opacity: .55; background: transparent; cursor: default; }
302
310
  .rz-sidebar--collapsed .rz-sidebar__item { justify-content: center; padding: .55rem 0; }
303
311
  .rz-sidebar--collapsed .rz-sidebar__label { display: none; }
312
+ .rz-sidebar__pinned { border-top: 1px solid var(--separator); padding: .3rem 0; }
304
313
  .rz-sidebar__collapse { display: flex; align-items: center; gap: .7rem; padding: .6rem 1rem; border: 0; border-top: 1px solid var(--separator); background: transparent; color: var(--text-muted); cursor: pointer; font: inherit; font-size: .8rem; text-align: left; }
305
314
  .rz-sidebar__collapse:hover { color: var(--text-primary); }
306
315
  .rz-sidebar__collapse svg { flex-shrink: 0; }
@@ -411,6 +420,7 @@ var SHELL_CSS = `
411
420
  .rz-sidebar__item { flex-direction: column; gap: .2rem; font-size: .6875rem; padding: .4rem .6rem; border-radius: 6px; }
412
421
  .rz-sidebar--collapsed .rz-sidebar__label { display: block; }
413
422
  .rz-sidebar__collapse { display: none; }
423
+ .rz-sidebar__pinned { display: flex; border-top: 0; padding: .35rem .25rem; }
414
424
  .rz-topbar__greeting { display: none; }
415
425
  .rz-content { padding: 18px 14px 60px; }
416
426
  /* The sidebar is a top strip now, so it must not be sticky-height; the topbar stays pinned. */
@@ -483,6 +493,7 @@ function AdminShell({
483
493
  user,
484
494
  role,
485
495
  nav,
496
+ pinnedNav,
486
497
  activeHref,
487
498
  basePath = "/admin",
488
499
  signOutPath,
@@ -508,7 +519,7 @@ function AdminShell({
508
519
  }
509
520
  ),
510
521
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rz-body", children: [
511
- showNav && /* @__PURE__ */ jsxRuntime.jsx(Sidebar, { items: nav, activeHref, basePath }),
522
+ showNav && /* @__PURE__ */ jsxRuntime.jsx(Sidebar, { items: nav, pinned: pinnedNav, activeHref, basePath }),
512
523
  /* @__PURE__ */ jsxRuntime.jsx("main", { className: "rz-main", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rz-content", children }) })
513
524
  ] })
514
525
  ] });