@drawnagency/primitives 0.1.50 → 0.1.51
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/{chunk-ONBJG426.js → chunk-24SUF2BC.js} +3 -0
- package/dist/{chunk-DLXIYIG2.js → chunk-KDGYHU36.js} +1 -1
- package/dist/{chunk-ICRCH3GI.js → chunk-PUNXQK4M.js} +1 -1
- package/dist/components/editor/MoveSectionModal.d.ts.map +1 -1
- package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
- package/dist/components/primitives/tiptap-presets.d.ts.map +1 -1
- package/dist/components/shared/LinkField.d.ts.map +1 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/RadioGroup.d.ts +15 -0
- package/dist/components/shared/RadioGroup.d.ts.map +1 -0
- package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/index.js +2 -2
- package/dist/lib/links.d.ts +14 -3
- package/dist/lib/links.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +1 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/editor/MoveSectionModal.tsx +10 -12
- package/src/components/editor/PagesModal.tsx +17 -9
- package/src/components/primitives/LinkPopover.tsx +99 -27
- package/src/components/primitives/tiptap-presets.ts +3 -1
- package/src/components/shared/LinkField.tsx +10 -7
- package/src/components/shared/Navigation.tsx +22 -7
- package/src/components/shared/RadioGroup.tsx +71 -0
- package/src/components/shell/EditorShell.tsx +7 -7
- package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
- package/src/hooks/useBuildStatus.ts +19 -4
- package/src/lib/links.ts +76 -9
- package/src/schemas/site-config.ts +6 -0
|
@@ -266,6 +266,9 @@ var SiteConfigSchema = z3.object({
|
|
|
266
266
|
uppercaseSubheadings: z3.boolean().default(true),
|
|
267
267
|
uppercaseNavHeadings: z3.boolean().default(true),
|
|
268
268
|
googleFontsUrl: z3.string().refine((url) => url.startsWith("https://fonts.googleapis.com/"), "Must be a Google Fonts URL").nullable().default(null),
|
|
269
|
+
// Favicon stored as a data URL (SVG/PNG, small) so it travels with
|
|
270
|
+
// site-config.json — no media manifest or CDN round-trip needed.
|
|
271
|
+
favicon: z3.string().refine((v) => v.startsWith("data:image/"), "Favicon must be an image data URL").nullable().default(null),
|
|
269
272
|
media: MediaConfigSchema.default(MediaConfigSchema.parse({}))
|
|
270
273
|
});
|
|
271
274
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MoveSectionModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/MoveSectionModal.tsx"],"names":[],"mappings":"AAKA,UAAU,KAAK;IACb,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,GAAG,QAAQ,KAAK,IAAI,CAAC;IACjE,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"MoveSectionModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/MoveSectionModal.tsx"],"names":[],"mappings":"AAKA,UAAU,KAAK;IACb,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,GAAG,QAAQ,KAAK,IAAI,CAAC;IACjE,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAuBjF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LinkPopover.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/LinkPopover.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"LinkPopover.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/LinkPopover.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAM3C,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAKD,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,gBAAgB,2CAqKhE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tiptap-presets.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/tiptap-presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"tiptap-presets.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/tiptap-presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAiD/C,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,MAAM,CAAC;AAE1C,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,UAAU,EAAE,UAAU,CAAmB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LinkField.d.ts","sourceRoot":"","sources":["../../../src/components/shared/LinkField.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAGpD,UAAU,KAAK;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;CACtC;AAOD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"LinkField.d.ts","sourceRoot":"","sources":["../../../src/components/shared/LinkField.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAGpD,UAAU,KAAK;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;CACtC;AAOD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAuE1D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../../src/components/shared/Navigation.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../../src/components/shared/Navigation.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAW,MAAM,eAAe,CAAC;AAOtD,UAAU,KAAK;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;IACxC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAG3B,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,EAAE,KAAK,2CAsO/G"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface RadioGroupOption<T extends string = string> {
|
|
2
|
+
label: string;
|
|
3
|
+
value: T;
|
|
4
|
+
}
|
|
5
|
+
interface RadioGroupProps<T extends string = string> {
|
|
6
|
+
label: string;
|
|
7
|
+
options: RadioGroupOption<T>[];
|
|
8
|
+
value: T;
|
|
9
|
+
onChange: (value: T) => void;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function RadioGroup<T extends string = string>({ label, options, value, onChange, disabled, className, }: RadioGroupProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=RadioGroup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RadioGroup.d.ts","sourceRoot":"","sources":["../../../src/components/shared/RadioGroup.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,CAAC,CAAC;CACV;AAED,UAAU,eAAe,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC;IAC/B,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EACpD,KAAK,EACL,OAAO,EACP,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAS,GACV,EAAE,eAAe,CAAC,CAAC,CAAC,2CA6CpB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SiteSettingsDisplay.d.ts","sourceRoot":"","sources":["../../../src/components/shell/SiteSettingsDisplay.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SiteSettingsDisplay.d.ts","sourceRoot":"","sources":["../../../src/components/shell/SiteSettingsDisplay.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,UAAU,KAAK;IACb,UAAU,EAAE,UAAU,CAAC;IACvB,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;CACxC;AAWD,wBAAgB,mBAAmB,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAuIlE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useBuildStatus.d.ts","sourceRoot":"","sources":["../../src/hooks/useBuildStatus.ts"],"names":[],"mappings":"AAEA,KAAK,UAAU,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC;AASrE,UAAU,iBAAiB;IACzB,KAAK,EAAE,UAAU,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;
|
|
1
|
+
{"version":3,"file":"useBuildStatus.d.ts","sourceRoot":"","sources":["../../src/hooks/useBuildStatus.ts"],"names":[],"mappings":"AAEA,KAAK,UAAU,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC;AASrE,UAAU,iBAAiB;IACzB,KAAK,EAAE,UAAU,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAUD,wBAAgB,cAAc,IAAI,iBAAiB,CA2IlD"}
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
LinkValueSchema,
|
|
7
7
|
MediaGridOptionsSchema,
|
|
8
8
|
slugifyAudienceName
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-PUNXQK4M.js";
|
|
10
10
|
import {
|
|
11
11
|
AudienceSchema,
|
|
12
12
|
RoleSchema,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
safeRedirect,
|
|
34
34
|
sanitizeHtml,
|
|
35
35
|
toSectionId
|
|
36
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-KDGYHU36.js";
|
|
37
37
|
import {
|
|
38
38
|
ColorItemSchema,
|
|
39
39
|
ColorSpaceSchema,
|
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
normalizeSiteIndex,
|
|
59
59
|
registerSchema,
|
|
60
60
|
registerSection
|
|
61
|
-
} from "./chunk-
|
|
61
|
+
} from "./chunk-24SUF2BC.js";
|
|
62
62
|
import {
|
|
63
63
|
AUDIENCE_COOKIE,
|
|
64
64
|
LastOwnerError,
|
package/dist/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
safeRedirect,
|
|
19
19
|
sanitizeHtml,
|
|
20
20
|
toSectionId
|
|
21
|
-
} from "../chunk-
|
|
21
|
+
} from "../chunk-KDGYHU36.js";
|
|
22
22
|
import {
|
|
23
23
|
clearRegistry,
|
|
24
24
|
createRegistry,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
getSection,
|
|
30
30
|
registerSchema,
|
|
31
31
|
registerSection
|
|
32
|
-
} from "../chunk-
|
|
32
|
+
} from "../chunk-24SUF2BC.js";
|
|
33
33
|
import "../chunk-S2L3BPLS.js";
|
|
34
34
|
import {
|
|
35
35
|
env
|
package/dist/lib/links.d.ts
CHANGED
|
@@ -6,9 +6,20 @@ export declare function resolveLinkHref(link: LinkValue, index: SiteIndex, headi
|
|
|
6
6
|
target: string;
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
9
|
+
* Stable internal-link href stored inside rich text (TipTap link marks):
|
|
10
|
+
* `page://{pageId}` or `page://{pageId}#{anchorSectionId}`. Page ids survive
|
|
11
|
+
* slug renames; the SSR pass rewrites these to real routes for the viewer.
|
|
12
|
+
*/
|
|
13
|
+
export declare function internalHref(pageId: string, anchorSectionId?: string | null): string;
|
|
14
|
+
export declare function parseInternalHref(href: string): {
|
|
15
|
+
pageId: string;
|
|
16
|
+
anchorSectionId: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/**
|
|
19
|
+
* Rewrite any internal link embedded in a section's content into a resolved
|
|
20
|
+
* plain href so the zero-JS viewer renders a normal <a>. Covers structured
|
|
21
|
+
* LinkValue fields (button) and `page://` hrefs inside rich text HTML (prose,
|
|
22
|
+
* list items — any string field).
|
|
12
23
|
*/
|
|
13
24
|
export declare function resolveInternalLinks(section: Section, index: SiteIndex, headingById: Record<string, string | undefined>): Section;
|
|
14
25
|
//# sourceMappingURL=links.d.ts.map
|
package/dist/lib/links.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/lib/links.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD,wBAAgB,eAAe,CAC7B,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAWlC;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C,OAAO,
|
|
1
|
+
{"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/lib/links.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD,wBAAgB,eAAe,CAC7B,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAWlC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAEpF;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,GACX;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAG3D;AAwBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C,OAAO,CAoCT"}
|
package/dist/schemas/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
LinkValueSchema,
|
|
7
7
|
MediaGridOptionsSchema,
|
|
8
8
|
slugifyAudienceName
|
|
9
|
-
} from "../chunk-
|
|
9
|
+
} from "../chunk-PUNXQK4M.js";
|
|
10
10
|
import {
|
|
11
11
|
AudienceSchema,
|
|
12
12
|
RoleSchema,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
getSectionContentSchema,
|
|
29
29
|
getSectionSchema,
|
|
30
30
|
normalizeSiteIndex
|
|
31
|
-
} from "../chunk-
|
|
31
|
+
} from "../chunk-24SUF2BC.js";
|
|
32
32
|
import {
|
|
33
33
|
ImageManifestSchema,
|
|
34
34
|
MediaConfigSchema,
|
|
@@ -165,6 +165,7 @@ export declare const SiteConfigSchema: z.ZodObject<{
|
|
|
165
165
|
uppercaseSubheadings: z.ZodDefault<z.ZodBoolean>;
|
|
166
166
|
uppercaseNavHeadings: z.ZodDefault<z.ZodBoolean>;
|
|
167
167
|
googleFontsUrl: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
168
|
+
favicon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
168
169
|
media: z.ZodDefault<z.ZodObject<{
|
|
169
170
|
sizes: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
170
171
|
maxFileSize: z.ZodDefault<z.ZodNumber>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAED,eAAO,MAAM,gBAAgB
|
|
1
|
+
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAED,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;iBAqB3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { Select } from "../shared/Select";
|
|
3
3
|
import { Button } from "../shared/Button";
|
|
4
|
-
import {
|
|
4
|
+
import { RadioGroup } from "../shared/RadioGroup";
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
7
|
pages: { id: string; title: string }[];
|
|
@@ -18,17 +18,15 @@ export function MoveSectionModal({ pages, currentPageId, onMove, onCancel }: Pro
|
|
|
18
18
|
return (
|
|
19
19
|
<div className="flex flex-col gap-4">
|
|
20
20
|
<Select label="Destination page" value={dest} options={options} onChange={setDest} />
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
</div>
|
|
31
|
-
</div>
|
|
21
|
+
<RadioGroup
|
|
22
|
+
label="Position"
|
|
23
|
+
options={[
|
|
24
|
+
{ label: "Top", value: "top" },
|
|
25
|
+
{ label: "Bottom", value: "bottom" },
|
|
26
|
+
]}
|
|
27
|
+
value={position}
|
|
28
|
+
onChange={setPosition}
|
|
29
|
+
/>
|
|
32
30
|
<div className="flex justify-end gap-3">
|
|
33
31
|
<Button variant="secondary" size="md" onClick={onCancel}>Cancel</Button>
|
|
34
32
|
<Button variant="primary" size="md" disabled={!dest} onClick={() => onMove(dest, position)}>Move</Button>
|
|
@@ -322,15 +322,23 @@ function PageRow({
|
|
|
322
322
|
onChange={handleSlugChange}
|
|
323
323
|
/>
|
|
324
324
|
)}
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
325
|
+
<div className="flex items-start justify-between gap-4">
|
|
326
|
+
<div>
|
|
327
|
+
<label className="mb-1 block text-sm text-base-contrast">Audience</label>
|
|
328
|
+
<AudienceIndicator access={page.access} audiences={audiences} onChange={onSetAudience} />
|
|
329
|
+
{page.access.length === 0 && (
|
|
330
|
+
<p className="mt-1 text-xs text-base-contrast/70">
|
|
331
|
+
Viewers see this page; sections still apply their own audience rules.
|
|
332
|
+
</p>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
<Checkbox
|
|
336
|
+
label="Hide from Menu"
|
|
337
|
+
align="center"
|
|
338
|
+
checked={!page.showInNav}
|
|
339
|
+
onChange={(hidden) => onSetFields({ showInNav: !hidden })}
|
|
340
|
+
className="shrink-0"
|
|
341
|
+
/>
|
|
334
342
|
</div>
|
|
335
343
|
<div className="flex items-center gap-2">
|
|
336
344
|
<Button
|
|
@@ -1,27 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import type { Editor } from "@tiptap/core";
|
|
3
3
|
import { Button } from "../shared/Button";
|
|
4
|
+
import { usePages } from "../shared/PagesContext";
|
|
5
|
+
import { internalHref, parseInternalHref } from "../../lib/links";
|
|
6
|
+
import { cn } from "../../lib/cn";
|
|
4
7
|
|
|
5
8
|
interface LinkPopoverProps {
|
|
6
9
|
editor: Editor;
|
|
7
10
|
onClose: () => void;
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
const selectClasses =
|
|
14
|
+
"w-full rounded border border-base-contrast/20 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:ring-1 focus:ring-primary";
|
|
15
|
+
|
|
10
16
|
export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
|
|
17
|
+
const { pages, getPageHeadings } = usePages();
|
|
11
18
|
const existingAttrs = editor.getAttributes("link");
|
|
12
|
-
const
|
|
19
|
+
const existingInternal = parseInternalHref(existingAttrs.href ?? "");
|
|
20
|
+
|
|
21
|
+
const [mode, setMode] = useState<"external" | "internal">(existingInternal ? "internal" : "external");
|
|
22
|
+
const [url, setUrl] = useState<string>(existingInternal ? "" : existingAttrs.href ?? "");
|
|
23
|
+
const [pageId, setPageId] = useState<string>(existingInternal?.pageId ?? "");
|
|
24
|
+
const [anchorId, setAnchorId] = useState<string>(existingInternal?.anchorSectionId ?? "");
|
|
13
25
|
const [openInNewTab, setOpenInNewTab] = useState<boolean>(
|
|
14
26
|
existingAttrs.target === "_blank"
|
|
15
27
|
);
|
|
16
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
17
28
|
const hasLink = Boolean(existingAttrs.href);
|
|
18
|
-
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
inputRef.current?.focus();
|
|
21
|
-
}, []);
|
|
29
|
+
const headings = pageId ? getPageHeadings(pageId) : [];
|
|
22
30
|
|
|
23
31
|
const handleApply = () => {
|
|
24
|
-
if (
|
|
32
|
+
if (mode === "internal") {
|
|
33
|
+
if (pageId) {
|
|
34
|
+
editor
|
|
35
|
+
.chain()
|
|
36
|
+
.focus()
|
|
37
|
+
.extendMarkRange("link")
|
|
38
|
+
.setLink({ href: internalHref(pageId, anchorId || null), target: null })
|
|
39
|
+
.run();
|
|
40
|
+
}
|
|
41
|
+
} else if (url.trim()) {
|
|
25
42
|
editor
|
|
26
43
|
.chain()
|
|
27
44
|
.focus()
|
|
@@ -49,27 +66,82 @@ export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
|
|
|
49
66
|
|
|
50
67
|
return (
|
|
51
68
|
<div
|
|
52
|
-
className="flex flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
|
|
69
|
+
className="flex w-64 flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
|
|
53
70
|
onMouseDown={(e) => e.stopPropagation()}
|
|
71
|
+
onKeyDown={handleKeyDown}
|
|
54
72
|
>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
<div className="flex gap-2" role="tablist">
|
|
74
|
+
{(["external", "internal"] as const).map((kind) => (
|
|
75
|
+
<button
|
|
76
|
+
key={kind}
|
|
77
|
+
type="button"
|
|
78
|
+
role="tab"
|
|
79
|
+
aria-selected={mode === kind}
|
|
80
|
+
onClick={() => setMode(kind)}
|
|
81
|
+
className={cn(
|
|
82
|
+
"cursor-pointer rounded border px-2 py-0.5 text-xs",
|
|
83
|
+
mode === kind
|
|
84
|
+
? "border-primary bg-primary text-primary-contrast"
|
|
85
|
+
: "border-base-200 text-base-contrast",
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{kind === "external" ? "External URL" : "Internal page"}
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{mode === "external" ? (
|
|
94
|
+
<>
|
|
95
|
+
<input
|
|
96
|
+
autoFocus
|
|
97
|
+
type="url"
|
|
98
|
+
value={url}
|
|
99
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
100
|
+
placeholder="https://example.com"
|
|
101
|
+
className="rounded border border-base-contrast/20 bg-base px-2 py-1 text-sm text-base-contrast placeholder:text-base-contrast/40 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
102
|
+
/>
|
|
103
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm text-base-contrast">
|
|
104
|
+
<input
|
|
105
|
+
type="checkbox"
|
|
106
|
+
checked={openInNewTab}
|
|
107
|
+
onChange={(e) => setOpenInNewTab(e.target.checked)}
|
|
108
|
+
className="accent-primary"
|
|
109
|
+
/>
|
|
110
|
+
Open in new tab
|
|
111
|
+
</label>
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<select
|
|
116
|
+
autoFocus
|
|
117
|
+
aria-label="Page"
|
|
118
|
+
value={pageId}
|
|
119
|
+
onChange={(e) => {
|
|
120
|
+
setPageId(e.target.value);
|
|
121
|
+
setAnchorId("");
|
|
122
|
+
}}
|
|
123
|
+
className={selectClasses}
|
|
124
|
+
>
|
|
125
|
+
<option value="">Select a page…</option>
|
|
126
|
+
{pages.map((p) => (
|
|
127
|
+
<option key={p.id} value={p.id}>{p.title}</option>
|
|
128
|
+
))}
|
|
129
|
+
</select>
|
|
130
|
+
<select
|
|
131
|
+
aria-label="Section"
|
|
132
|
+
value={anchorId}
|
|
133
|
+
disabled={!pageId}
|
|
134
|
+
onChange={(e) => setAnchorId(e.target.value)}
|
|
135
|
+
className={cn(selectClasses, !pageId && "cursor-not-allowed opacity-50")}
|
|
136
|
+
>
|
|
137
|
+
<option value="">None (top of page)</option>
|
|
138
|
+
{headings.map((h) => (
|
|
139
|
+
<option key={h.id} value={h.id}>{h.label}</option>
|
|
140
|
+
))}
|
|
141
|
+
</select>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
|
|
73
145
|
<div className="flex gap-2">
|
|
74
146
|
<Button
|
|
75
147
|
variant="brand"
|
|
@@ -42,7 +42,9 @@ const rich: Extensions = [
|
|
|
42
42
|
}),
|
|
43
43
|
CustomParagraph,
|
|
44
44
|
Underline,
|
|
45
|
-
|
|
45
|
+
// `page` protocol: stable internal-page hrefs (page://{pageId}#{sectionId})
|
|
46
|
+
// resolved to real routes at SSR — see lib/links.ts.
|
|
47
|
+
Link.configure({ openOnClick: false, protocols: ["page"] }),
|
|
46
48
|
];
|
|
47
49
|
|
|
48
50
|
export type PresetName = "basic" | "rich";
|
|
@@ -22,9 +22,10 @@ export function LinkField({ label, value, onChange }: Props) {
|
|
|
22
22
|
|
|
23
23
|
function setMode(kind: "external" | "internal") {
|
|
24
24
|
if (kind === value.kind) return;
|
|
25
|
+
// Internal links always open in the same tab — no target choice offered.
|
|
25
26
|
onChange(kind === "external"
|
|
26
27
|
? { kind: "external", href: "", target: value.target }
|
|
27
|
-
: { kind: "internal", pageId: "", anchorSectionId: null, target:
|
|
28
|
+
: { kind: "internal", pageId: "", anchorSectionId: null, target: "_self" });
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const pageId = isInternal ? value.pageId : "";
|
|
@@ -76,12 +77,14 @@ export function LinkField({ label, value, onChange }: Props) {
|
|
|
76
77
|
</>
|
|
77
78
|
)}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
{value.kind === "external" && (
|
|
81
|
+
<Select
|
|
82
|
+
label="Opens in"
|
|
83
|
+
value={value.target}
|
|
84
|
+
options={TARGET_OPTIONS}
|
|
85
|
+
onChange={(t) => onChange({ ...value, target: t as "_self" | "_blank" })}
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
85
88
|
</div>
|
|
86
89
|
);
|
|
87
90
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
2
3
|
import { cn } from "../../lib/cn";
|
|
3
4
|
import { Toggle } from "./Toggle";
|
|
4
5
|
import type { SiteNav, NavItem } from "../../lib/nav";
|
|
@@ -25,6 +26,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
25
26
|
const [isDark, setIsDark] = useState(false);
|
|
26
27
|
const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
|
|
27
28
|
const [showHistory, setShowHistory] = useState(false);
|
|
29
|
+
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
|
28
30
|
const historyButtonRef = useRef<HTMLButtonElement>(null);
|
|
29
31
|
|
|
30
32
|
useEffect(() => {
|
|
@@ -164,13 +166,26 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
164
166
|
</li>
|
|
165
167
|
);
|
|
166
168
|
|
|
167
|
-
const renderPageGroup = (label: string, pages: SiteNav["pages"]) =>
|
|
168
|
-
pages.length
|
|
169
|
+
const renderPageGroup = (label: string, pages: SiteNav["pages"]) => {
|
|
170
|
+
if (pages.length === 0) return null;
|
|
171
|
+
// Closed by default; auto-open when it holds the active page so the
|
|
172
|
+
// editor's current location (and its heading tree) stays visible.
|
|
173
|
+
const isOpen = openGroups[label] ?? pages.some((p) => p.isActive);
|
|
174
|
+
return (
|
|
169
175
|
<div className="mx-4 mt-2 border-t border-base-200 pt-2">
|
|
170
|
-
<
|
|
171
|
-
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
aria-expanded={isOpen}
|
|
179
|
+
onClick={() => setOpenGroups((g) => ({ ...g, [label]: !isOpen }))}
|
|
180
|
+
className="flex w-full cursor-pointer items-center justify-between rounded px-3 py-1 text-xs font-semibold uppercase tracking-wide text-base-contrast/70 transition-colors hover:text-primary"
|
|
181
|
+
>
|
|
182
|
+
<span>{`${label} (${pages.length})`}</span>
|
|
183
|
+
<ChevronRight size={14} aria-hidden="true" className={cn("transition-transform", isOpen && "rotate-90")} />
|
|
184
|
+
</button>
|
|
185
|
+
{isOpen && <ul className="space-y-1">{pages.map(renderPage)}</ul>}
|
|
172
186
|
</div>
|
|
173
187
|
);
|
|
188
|
+
};
|
|
174
189
|
|
|
175
190
|
return (
|
|
176
191
|
<>
|
|
@@ -199,13 +214,13 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
199
214
|
{siteNav.pages.map(renderPage)}
|
|
200
215
|
</ul>
|
|
201
216
|
|
|
202
|
-
{renderPageGroup("Hidden from Menu", siteNav.hidden)}
|
|
203
|
-
{renderPageGroup("Archive", siteNav.archive)}
|
|
217
|
+
{isEditMode && renderPageGroup("Hidden from Menu", siteNav.hidden)}
|
|
218
|
+
{isEditMode && renderPageGroup("Archive", siteNav.archive)}
|
|
204
219
|
|
|
205
220
|
<div className="mt-auto">
|
|
206
221
|
{currentDarkMode === "optional" && (
|
|
207
222
|
<div className="mx-4 border-t border-base-200 py-4">
|
|
208
|
-
<div className="flex items-center gap-2 text-sm text-base-contrast-light">
|
|
223
|
+
<div className="flex items-center justify-center gap-2 text-sm text-base-contrast-light">
|
|
209
224
|
<span className={cn(!isDark && "text-base-contrast")}>Light</span>
|
|
210
225
|
<Toggle checked={isDark} onChange={handleThemeToggle} label="Toggle dark mode" />
|
|
211
226
|
<span className={cn(isDark && "text-base-contrast")}>Dark</span>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { FormLabel } from "./FormLabel";
|
|
4
|
+
|
|
5
|
+
export interface RadioGroupOption<T extends string = string> {
|
|
6
|
+
label: string;
|
|
7
|
+
value: T;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface RadioGroupProps<T extends string = string> {
|
|
11
|
+
label: string;
|
|
12
|
+
options: RadioGroupOption<T>[];
|
|
13
|
+
value: T;
|
|
14
|
+
onChange: (value: T) => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function RadioGroup<T extends string = string>({
|
|
20
|
+
label,
|
|
21
|
+
options,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
disabled,
|
|
25
|
+
className,
|
|
26
|
+
}: RadioGroupProps<T>) {
|
|
27
|
+
const name = useId();
|
|
28
|
+
return (
|
|
29
|
+
<div className={className} role="radiogroup" aria-label={label}>
|
|
30
|
+
<FormLabel>{label}</FormLabel>
|
|
31
|
+
<div className="flex gap-4">
|
|
32
|
+
{options.map((option) => {
|
|
33
|
+
const checked = value === option.value;
|
|
34
|
+
return (
|
|
35
|
+
<label
|
|
36
|
+
key={option.value}
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex cursor-pointer items-center gap-2 select-none hover:opacity-80",
|
|
39
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
|
|
43
|
+
<input
|
|
44
|
+
type="radio"
|
|
45
|
+
name={name}
|
|
46
|
+
checked={checked}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
onChange={() => {
|
|
49
|
+
if (!disabled) onChange(option.value);
|
|
50
|
+
}}
|
|
51
|
+
className="sr-only"
|
|
52
|
+
/>
|
|
53
|
+
<span
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
className={cn(
|
|
56
|
+
"flex h-5 w-5 items-center justify-center rounded-full border transition-colors",
|
|
57
|
+
checked ? "border-primary" : "border-base-300 bg-base hover:border-primary",
|
|
58
|
+
disabled && "pointer-events-none",
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{checked && <span className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
|
62
|
+
</span>
|
|
63
|
+
</span>
|
|
64
|
+
<span className="text-sm font-medium text-base-contrast">{option.label}</span>
|
|
65
|
+
</label>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -1466,13 +1466,6 @@ function EditorToolbar({
|
|
|
1466
1466
|
</div>
|
|
1467
1467
|
<div className="flex items-center justify-end gap-2">
|
|
1468
1468
|
<ProcessingIndicator items={processingItems} />
|
|
1469
|
-
<IconButton
|
|
1470
|
-
icon={<FilesIcon size={16} />}
|
|
1471
|
-
label="Pages"
|
|
1472
|
-
size="md"
|
|
1473
|
-
onClick={onPagesClick}
|
|
1474
|
-
className="border border-base-200 bg-base-accent"
|
|
1475
|
-
/>
|
|
1476
1469
|
<IconButton
|
|
1477
1470
|
icon={<ListOrderedIcon size={16} />}
|
|
1478
1471
|
label="Reorder sections"
|
|
@@ -1487,6 +1480,13 @@ function EditorToolbar({
|
|
|
1487
1480
|
onClick={onMediaClick}
|
|
1488
1481
|
className="border border-base-200 bg-base-accent"
|
|
1489
1482
|
/>
|
|
1483
|
+
<IconButton
|
|
1484
|
+
icon={<FilesIcon size={16} />}
|
|
1485
|
+
label="Pages"
|
|
1486
|
+
size="md"
|
|
1487
|
+
onClick={onPagesClick}
|
|
1488
|
+
className="border border-base-200 bg-base-accent"
|
|
1489
|
+
/>
|
|
1490
1490
|
<IconButton
|
|
1491
1491
|
icon={<SettingsIcon size={16} />}
|
|
1492
1492
|
label="Site settings"
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Button } from "../shared/Button";
|
|
1
3
|
import { Checkbox } from "../shared/Checkbox";
|
|
2
4
|
import { ColorPicker } from "../shared/ColorPicker";
|
|
3
5
|
import { FontPicker } from "../shared/FontPicker";
|
|
@@ -19,11 +21,32 @@ const darkModeOptions = [
|
|
|
19
21
|
{ label: "Optional (viewer toggle)", value: "optional" },
|
|
20
22
|
];
|
|
21
23
|
|
|
24
|
+
const FAVICON_TYPES = ["image/svg+xml", "image/png"];
|
|
25
|
+
const FAVICON_MAX_BYTES = 100 * 1024;
|
|
26
|
+
|
|
22
27
|
export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
28
|
+
const [faviconError, setFaviconError] = useState<string | null>(null);
|
|
29
|
+
|
|
23
30
|
function update(patch: Partial<SiteConfig>) {
|
|
24
31
|
onChange({ ...siteConfig, ...patch });
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
function handleFaviconFile(file: File | undefined) {
|
|
35
|
+
if (!file) return;
|
|
36
|
+
if (!FAVICON_TYPES.includes(file.type)) {
|
|
37
|
+
setFaviconError("Favicon must be an SVG or PNG.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (file.size > FAVICON_MAX_BYTES) {
|
|
41
|
+
setFaviconError("Favicon must be 100KB or smaller.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setFaviconError(null);
|
|
45
|
+
const reader = new FileReader();
|
|
46
|
+
reader.onload = () => update({ favicon: reader.result as string });
|
|
47
|
+
reader.readAsDataURL(file);
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
function handleColorChange(color: string) {
|
|
28
51
|
const contrast = deriveContrast(color);
|
|
29
52
|
onChange({ ...siteConfig, primaryColor: color, primaryContrast: contrast });
|
|
@@ -56,6 +79,48 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
|
56
79
|
</div>
|
|
57
80
|
</div>
|
|
58
81
|
|
|
82
|
+
<div>
|
|
83
|
+
<FormLabel>Favicon</FormLabel>
|
|
84
|
+
<div className="flex items-center gap-3">
|
|
85
|
+
{siteConfig.favicon && (
|
|
86
|
+
<img
|
|
87
|
+
src={siteConfig.favicon}
|
|
88
|
+
alt="Current favicon"
|
|
89
|
+
className="h-8 w-8 rounded border border-base-200 bg-base-accent object-contain p-1"
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
<label className="inline-flex cursor-pointer items-center rounded border border-base-200 px-3 py-1.5 text-xs font-medium text-base-contrast transition-colors hover:bg-base-accent">
|
|
93
|
+
{siteConfig.favicon ? "Replace" : "Upload"}
|
|
94
|
+
<input
|
|
95
|
+
type="file"
|
|
96
|
+
accept="image/svg+xml,image/png,.svg,.png"
|
|
97
|
+
aria-label="Upload favicon"
|
|
98
|
+
className="sr-only"
|
|
99
|
+
onChange={(e) => {
|
|
100
|
+
handleFaviconFile(e.target.files?.[0]);
|
|
101
|
+
e.target.value = "";
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
{siteConfig.favicon && (
|
|
106
|
+
<Button
|
|
107
|
+
variant="ghost"
|
|
108
|
+
tone="destructive"
|
|
109
|
+
size="sm"
|
|
110
|
+
aria-label="Remove favicon"
|
|
111
|
+
onClick={() => {
|
|
112
|
+
setFaviconError(null);
|
|
113
|
+
update({ favicon: null });
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
Remove
|
|
117
|
+
</Button>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
<p className="mt-1 text-xs text-base-contrast/70">SVG or PNG, up to 100KB. Shown in browser tabs.</p>
|
|
121
|
+
{faviconError && <p className="mt-1 text-xs text-red-600">{faviconError}</p>}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
59
124
|
<Select
|
|
60
125
|
label="Dark mode"
|
|
61
126
|
value={siteConfig.darkMode}
|
|
@@ -19,6 +19,10 @@ interface BuildStatusResult {
|
|
|
19
19
|
const POLL_INTERVAL = 5000;
|
|
20
20
|
const AUTO_CLEAR_DELAY = 10000;
|
|
21
21
|
const FADE_DURATION = 1000;
|
|
22
|
+
// A "building" row older than this on initial load is a leftover from a publish
|
|
23
|
+
// whose deploy was never confirmed (e.g. superseded by a later build) — show
|
|
24
|
+
// idle rather than a phantom in-progress publish.
|
|
25
|
+
const STALE_BUILD_MS = 15 * 60 * 1000;
|
|
22
26
|
|
|
23
27
|
export function useBuildStatus(): BuildStatusResult {
|
|
24
28
|
const [state, setState] = useState<BuildState>("idle");
|
|
@@ -34,9 +38,9 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
34
38
|
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
|
35
39
|
}, []);
|
|
36
40
|
|
|
37
|
-
const startTimer = useCallback(() => {
|
|
41
|
+
const startTimer = useCallback((initialSeconds = 0) => {
|
|
38
42
|
stopTimer();
|
|
39
|
-
setElapsedSeconds(
|
|
43
|
+
setElapsedSeconds(initialSeconds);
|
|
40
44
|
timerRef.current = setInterval(() => setElapsedSeconds((s) => s + 1), 1000);
|
|
41
45
|
}, [stopTimer]);
|
|
42
46
|
|
|
@@ -114,11 +118,22 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
114
118
|
async function check() {
|
|
115
119
|
const data = await fetchStatus();
|
|
116
120
|
if (cancelled) return;
|
|
117
|
-
handleStatusUpdate(data, true);
|
|
118
121
|
|
|
119
122
|
if (data && data.state === "building") {
|
|
123
|
+
const ageMs = Date.now() - Date.parse(data.updatedAt);
|
|
124
|
+
if (Number.isFinite(ageMs) && ageMs > STALE_BUILD_MS) {
|
|
125
|
+
setState("idle");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
setState("building");
|
|
129
|
+
// Seed the timer from when the build actually started so the display
|
|
130
|
+
// counts real elapsed time instead of sitting frozen at 0:00.
|
|
131
|
+
startTimer(Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 1000)) : 0);
|
|
120
132
|
startPolling();
|
|
133
|
+
return;
|
|
121
134
|
}
|
|
135
|
+
|
|
136
|
+
handleStatusUpdate(data, true);
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
check();
|
|
@@ -131,7 +146,7 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
131
146
|
if (clearRef.current) clearTimeout(clearRef.current);
|
|
132
147
|
if (fadeRef.current) clearTimeout(fadeRef.current);
|
|
133
148
|
};
|
|
134
|
-
}, [fetchStatus, handleStatusUpdate, startPolling, stopPolling, stopTimer]);
|
|
149
|
+
}, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer]);
|
|
135
150
|
|
|
136
151
|
const startTracking = useCallback(() => {
|
|
137
152
|
if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
|
package/src/lib/links.ts
CHANGED
|
@@ -22,20 +22,87 @@ export function resolveLinkHref(
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* the
|
|
25
|
+
* Stable internal-link href stored inside rich text (TipTap link marks):
|
|
26
|
+
* `page://{pageId}` or `page://{pageId}#{anchorSectionId}`. Page ids survive
|
|
27
|
+
* slug renames; the SSR pass rewrites these to real routes for the viewer.
|
|
28
|
+
*/
|
|
29
|
+
export function internalHref(pageId: string, anchorSectionId?: string | null): string {
|
|
30
|
+
return `page://${pageId}${anchorSectionId ? `#${anchorSectionId}` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseInternalHref(
|
|
34
|
+
href: string,
|
|
35
|
+
): { pageId: string; anchorSectionId: string | null } | null {
|
|
36
|
+
const m = /^page:\/\/([^#]+)(?:#(.+))?$/.exec(href);
|
|
37
|
+
return m ? { pageId: m[1], anchorSectionId: m[2] ?? null } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveHtmlString(
|
|
41
|
+
html: string,
|
|
42
|
+
index: SiteIndex,
|
|
43
|
+
headingById: Record<string, string | undefined>,
|
|
44
|
+
): string {
|
|
45
|
+
return html.replace(/href="page:\/\/([^"]*)"/g, (_match, ref: string) => {
|
|
46
|
+
const hash = ref.indexOf("#");
|
|
47
|
+
const pageId = hash === -1 ? ref : ref.slice(0, hash);
|
|
48
|
+
const anchorSectionId = hash === -1 ? null : ref.slice(hash + 1) || null;
|
|
49
|
+
const { href } = resolveLinkHref(
|
|
50
|
+
{ kind: "internal", pageId, anchorSectionId, target: "_self" },
|
|
51
|
+
index,
|
|
52
|
+
headingById,
|
|
53
|
+
);
|
|
54
|
+
return `href="${href}"`;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isInternalLinkValue(value: Record<string, unknown>): value is LinkValue & { kind: "internal" } {
|
|
59
|
+
return value.kind === "internal" && typeof value.pageId === "string";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Rewrite any internal link embedded in a section's content into a resolved
|
|
64
|
+
* plain href so the zero-JS viewer renders a normal <a>. Covers structured
|
|
65
|
+
* LinkValue fields (button) and `page://` hrefs inside rich text HTML (prose,
|
|
66
|
+
* list items — any string field).
|
|
28
67
|
*/
|
|
29
68
|
export function resolveInternalLinks(
|
|
30
69
|
section: Section,
|
|
31
70
|
index: SiteIndex,
|
|
32
71
|
headingById: Record<string, string | undefined>,
|
|
33
72
|
): Section {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
// Copy-on-write: untouched subtrees keep their original references so the
|
|
74
|
+
// per-request SSR pass allocates nothing for the common no-links case.
|
|
75
|
+
const walk = (value: unknown): unknown => {
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
return value.includes("page://") ? resolveHtmlString(value, index, headingById) : value;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
let changed = false;
|
|
81
|
+
const next = value.map((v) => {
|
|
82
|
+
const w = walk(v);
|
|
83
|
+
if (w !== v) changed = true;
|
|
84
|
+
return w;
|
|
85
|
+
});
|
|
86
|
+
return changed ? next : value;
|
|
87
|
+
}
|
|
88
|
+
if (value !== null && typeof value === "object") {
|
|
89
|
+
const obj = value as Record<string, unknown>;
|
|
90
|
+
if (isInternalLinkValue(obj)) {
|
|
91
|
+
const resolved = resolveLinkHref(obj, index, headingById);
|
|
92
|
+
return { kind: "external", href: resolved.href, target: resolved.target };
|
|
93
|
+
}
|
|
94
|
+
let changed = false;
|
|
95
|
+
const result: Record<string, unknown> = {};
|
|
96
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
97
|
+
const w = walk(v);
|
|
98
|
+
result[key] = w;
|
|
99
|
+
if (w !== v) changed = true;
|
|
100
|
+
}
|
|
101
|
+
return changed ? result : value;
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
40
104
|
};
|
|
105
|
+
|
|
106
|
+
const content = walk(section.content);
|
|
107
|
+
return content === section.content ? section : ({ ...section, content } as Section);
|
|
41
108
|
}
|
|
@@ -146,6 +146,12 @@ export const SiteConfigSchema = z.object({
|
|
|
146
146
|
.refine(url => url.startsWith("https://fonts.googleapis.com/"), "Must be a Google Fonts URL")
|
|
147
147
|
.nullable()
|
|
148
148
|
.default(null),
|
|
149
|
+
// Favicon stored as a data URL (SVG/PNG, small) so it travels with
|
|
150
|
+
// site-config.json — no media manifest or CDN round-trip needed.
|
|
151
|
+
favicon: z.string()
|
|
152
|
+
.refine((v) => v.startsWith("data:image/"), "Favicon must be an image data URL")
|
|
153
|
+
.nullable()
|
|
154
|
+
.default(null),
|
|
149
155
|
media: MediaConfigSchema.default(MediaConfigSchema.parse({})),
|
|
150
156
|
});
|
|
151
157
|
|