@realiizlabs/admin 0.8.1 → 0.9.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/dist/auth-ui/index.cjs +17 -1
- package/dist/auth-ui/index.cjs.map +1 -1
- package/dist/auth-ui/index.js +1 -1
- package/dist/{chunk-CE2DRQBD.js → chunk-FTD4WE3V.js} +19 -3
- package/dist/chunk-FTD4WE3V.js.map +1 -0
- package/dist/forms-ui/index.cjs +243 -36
- package/dist/forms-ui/index.cjs.map +1 -1
- package/dist/forms-ui/index.d.cts +69 -12
- package/dist/forms-ui/index.d.ts +69 -12
- package/dist/forms-ui/index.js +226 -36
- package/dist/forms-ui/index.js.map +1 -1
- package/dist/git/index.cjs +18 -2
- package/dist/git/index.cjs.map +1 -1
- package/dist/git/index.js +18 -2
- package/dist/git/index.js.map +1 -1
- package/dist/media/index.cjs +95 -0
- package/dist/media/index.cjs.map +1 -0
- package/dist/media/index.d.cts +65 -0
- package/dist/media/index.d.ts +65 -0
- package/dist/media/index.js +85 -0
- package/dist/media/index.js.map +1 -0
- package/dist/shell/index.cjs +31 -24
- package/dist/shell/index.cjs.map +1 -1
- package/dist/shell/index.d.cts +6 -3
- package/dist/shell/index.d.ts +6 -3
- package/dist/shell/index.js +31 -24
- package/dist/shell/index.js.map +1 -1
- package/package.json +6 -1
- package/dist/chunk-CE2DRQBD.js.map +0 -1
|
@@ -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":"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","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"]}
|
package/dist/shell/index.cjs
CHANGED
|
@@ -70,7 +70,7 @@ function isActive(item, activeHref, basePath) {
|
|
|
70
70
|
if (item.href === basePath) return activeHref === basePath || activeHref === `${basePath}/`;
|
|
71
71
|
return activeHref === item.href || activeHref.startsWith(`${item.href}/`);
|
|
72
72
|
}
|
|
73
|
-
function Sidebar({ items, activeHref, basePath }) {
|
|
73
|
+
function Sidebar({ items, pinned = [], activeHref, basePath }) {
|
|
74
74
|
const [collapsed, setCollapsed] = react.useState(false);
|
|
75
75
|
react.useEffect(() => {
|
|
76
76
|
try {
|
|
@@ -86,30 +86,32 @@ function Sidebar({ items, activeHref, basePath }) {
|
|
|
86
86
|
} catch {
|
|
87
87
|
}
|
|
88
88
|
};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
const renderItem = (item) => {
|
|
90
|
+
const active = !item.disabled && isActive(item, activeHref, basePath);
|
|
91
|
+
if (item.disabled) {
|
|
92
|
+
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
|
+
/* @__PURE__ */ jsxRuntime.jsx(Icon, { name: iconFor(item.icon ?? item.id) }),
|
|
94
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "rz-sidebar__label", children: item.label })
|
|
95
|
+
] }, item.id);
|
|
96
|
+
}
|
|
97
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
98
|
+
"a",
|
|
99
|
+
{
|
|
100
|
+
href: item.href,
|
|
101
|
+
className: ["rz-sidebar__item", active && "rz-sidebar__item--active"].filter(Boolean).join(" "),
|
|
102
|
+
"aria-current": active ? "page" : void 0,
|
|
103
|
+
title: collapsed ? item.label : void 0,
|
|
104
|
+
children: [
|
|
94
105
|
/* @__PURE__ */ jsxRuntime.jsx(Icon, { name: iconFor(item.icon ?? item.id) }),
|
|
95
106
|
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "rz-sidebar__label", children: item.label })
|
|
96
|
-
]
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
}) }),
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
item.id
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: ["rz-sidebar", collapsed && "rz-sidebar--collapsed"].filter(Boolean).join(" "), "aria-label": "Admin navigation", children: [
|
|
113
|
+
/* @__PURE__ */ jsxRuntime.jsx("nav", { className: "rz-sidebar__nav", children: items.map(renderItem) }),
|
|
114
|
+
pinned.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("nav", { className: "rz-sidebar__pinned", "aria-label": "Settings", children: pinned.map(renderItem) }),
|
|
113
115
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
114
116
|
"button",
|
|
115
117
|
{
|
|
@@ -278,6 +280,8 @@ var SHELL_CSS = `
|
|
|
278
280
|
.rz-admin a { color: inherit; }
|
|
279
281
|
.rz-admin h1, .rz-admin h2, .rz-admin h3 { font-family: var(--rz-font-display); color: var(--text-primary); margin: 0; }
|
|
280
282
|
.rz-admin .realiiz-form__control { background: var(--surface-0); }
|
|
283
|
+
.rz-admin .realiiz-image { background: var(--surface-0); }
|
|
284
|
+
.rz-admin .realiiz-image__thumb { background: var(--surface-2); }
|
|
281
285
|
.rz-admin[data-theme="dark"] .realiiz-form__control::-webkit-calendar-picker-indicator { filter: invert(1) opacity(.7); }
|
|
282
286
|
.rz-admin .realiiz-form__button { background: var(--surface-1); color: var(--text-primary); }
|
|
283
287
|
.rz-admin .realiiz-form__button--primary { background: var(--signal); color: var(--signal-fg); }
|
|
@@ -301,6 +305,7 @@ var SHELL_CSS = `
|
|
|
301
305
|
.rz-sidebar__item--disabled, .rz-sidebar__item--disabled:hover { color: var(--text-muted); opacity: .55; background: transparent; cursor: default; }
|
|
302
306
|
.rz-sidebar--collapsed .rz-sidebar__item { justify-content: center; padding: .55rem 0; }
|
|
303
307
|
.rz-sidebar--collapsed .rz-sidebar__label { display: none; }
|
|
308
|
+
.rz-sidebar__pinned { border-top: 1px solid var(--separator); padding: .3rem 0; }
|
|
304
309
|
.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
310
|
.rz-sidebar__collapse:hover { color: var(--text-primary); }
|
|
306
311
|
.rz-sidebar__collapse svg { flex-shrink: 0; }
|
|
@@ -411,6 +416,7 @@ var SHELL_CSS = `
|
|
|
411
416
|
.rz-sidebar__item { flex-direction: column; gap: .2rem; font-size: .6875rem; padding: .4rem .6rem; border-radius: 6px; }
|
|
412
417
|
.rz-sidebar--collapsed .rz-sidebar__label { display: block; }
|
|
413
418
|
.rz-sidebar__collapse { display: none; }
|
|
419
|
+
.rz-sidebar__pinned { display: flex; border-top: 0; padding: .35rem .25rem; }
|
|
414
420
|
.rz-topbar__greeting { display: none; }
|
|
415
421
|
.rz-content { padding: 18px 14px 60px; }
|
|
416
422
|
/* The sidebar is a top strip now, so it must not be sticky-height; the topbar stays pinned. */
|
|
@@ -483,6 +489,7 @@ function AdminShell({
|
|
|
483
489
|
user,
|
|
484
490
|
role,
|
|
485
491
|
nav,
|
|
492
|
+
pinnedNav,
|
|
486
493
|
activeHref,
|
|
487
494
|
basePath = "/admin",
|
|
488
495
|
signOutPath,
|
|
@@ -508,7 +515,7 @@ function AdminShell({
|
|
|
508
515
|
}
|
|
509
516
|
),
|
|
510
517
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rz-body", children: [
|
|
511
|
-
showNav && /* @__PURE__ */ jsxRuntime.jsx(Sidebar, { items: nav, activeHref, basePath }),
|
|
518
|
+
showNav && /* @__PURE__ */ jsxRuntime.jsx(Sidebar, { items: nav, pinned: pinnedNav, activeHref, basePath }),
|
|
512
519
|
/* @__PURE__ */ jsxRuntime.jsx("main", { className: "rz-main", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rz-content", children }) })
|
|
513
520
|
] })
|
|
514
521
|
] });
|