@nexpress/theme-default 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nexpress
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @nexpress/theme-default
2
+
3
+ Default theme for [NexPress](https://github.com/nexpress-cms/nexpress).
4
+ Neutral palette + system fonts; what every scaffolded site lands on
5
+ out of the box.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @nexpress/theme-default
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ // nexpress.config.ts
17
+ import defaultTheme from "@nexpress/theme-default";
18
+
19
+ export default defineConfig({
20
+ // ...
21
+ themes: [defaultTheme],
22
+ defaultTheme: defaultTheme.manifest.id,
23
+ });
24
+ ```
25
+
26
+ For authoring your own theme, see
27
+ [`@nexpress/theme`](https://www.npmjs.com/package/@nexpress/theme) and
28
+ [docs/theme-authoring.md](https://github.com/nexpress-cms/nexpress/blob/main/docs/theme-authoring.md).
29
+
30
+ ## License
31
+
32
+ MIT
@@ -0,0 +1,5 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ declare function DarkModeToggle(): react_jsx_runtime.JSX.Element;
4
+
5
+ export { DarkModeToggle };
@@ -0,0 +1,101 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/components/dark-mode-toggle.tsx
5
+ import {
6
+ COLOR_SCHEME_COOKIE,
7
+ COLOR_SCHEME_STORAGE_KEY,
8
+ isColorScheme
9
+ } from "@nexpress/theme/client";
10
+ import { useEffect, useState } from "react";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ var ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
13
+ function readCurrent() {
14
+ if (typeof document === "undefined") return null;
15
+ const attr = document.documentElement.dataset.theme;
16
+ return isColorScheme(attr) ? attr : null;
17
+ }
18
+ function writeChoice(choice) {
19
+ document.documentElement.dataset.theme = choice;
20
+ document.cookie = `${COLOR_SCHEME_COOKIE}=${choice}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`;
21
+ try {
22
+ window.localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, choice);
23
+ } catch {
24
+ }
25
+ }
26
+ function DarkModeToggle() {
27
+ const [mounted, setMounted] = useState(false);
28
+ const [scheme, setScheme] = useState(null);
29
+ useEffect(() => {
30
+ setMounted(true);
31
+ setScheme(readCurrent());
32
+ }, []);
33
+ if (!mounted) {
34
+ return /* @__PURE__ */ jsx(
35
+ "span",
36
+ {
37
+ className: "np-color-scheme-toggle np-color-scheme-toggle-placeholder",
38
+ "aria-hidden": "true"
39
+ }
40
+ );
41
+ }
42
+ const next = scheme === "dark" ? "light" : "dark";
43
+ const label = scheme === "dark" ? "Switch to light mode" : "Switch to dark mode";
44
+ return /* @__PURE__ */ jsx(
45
+ "button",
46
+ {
47
+ type: "button",
48
+ className: "np-color-scheme-toggle",
49
+ onClick: () => {
50
+ writeChoice(next);
51
+ setScheme(next);
52
+ },
53
+ "aria-label": label,
54
+ title: label,
55
+ children: scheme === "dark" ? /* @__PURE__ */ jsx(SunIcon, {}) : /* @__PURE__ */ jsx(MoonIcon, {})
56
+ }
57
+ );
58
+ }
59
+ function SunIcon() {
60
+ return /* @__PURE__ */ jsxs(
61
+ "svg",
62
+ {
63
+ xmlns: "http://www.w3.org/2000/svg",
64
+ width: "16",
65
+ height: "16",
66
+ viewBox: "0 0 24 24",
67
+ fill: "none",
68
+ stroke: "currentColor",
69
+ strokeWidth: "2",
70
+ strokeLinecap: "round",
71
+ strokeLinejoin: "round",
72
+ "aria-hidden": "true",
73
+ children: [
74
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "4" }),
75
+ /* @__PURE__ */ jsx("path", { d: "M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" })
76
+ ]
77
+ }
78
+ );
79
+ }
80
+ function MoonIcon() {
81
+ return /* @__PURE__ */ jsx(
82
+ "svg",
83
+ {
84
+ xmlns: "http://www.w3.org/2000/svg",
85
+ width: "16",
86
+ height: "16",
87
+ viewBox: "0 0 24 24",
88
+ fill: "none",
89
+ stroke: "currentColor",
90
+ strokeWidth: "2",
91
+ strokeLinecap: "round",
92
+ strokeLinejoin: "round",
93
+ "aria-hidden": "true",
94
+ children: /* @__PURE__ */ jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" })
95
+ }
96
+ );
97
+ }
98
+ export {
99
+ DarkModeToggle
100
+ };
101
+ //# sourceMappingURL=dark-mode-toggle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/dark-mode-toggle.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n COLOR_SCHEME_COOKIE,\n COLOR_SCHEME_STORAGE_KEY,\n isColorScheme,\n type NpColorScheme,\n} from \"@nexpress/theme/client\";\nimport { useEffect, useState } from \"react\";\n\n/**\n * Phase 11.5 — user-facing dark/light toggle.\n *\n * The early-init `<NpColorSchemeScript />` already set the\n * right attribute on `<html>` before this component mounted,\n * so we just read the current value and let the user flip it.\n * State persists in:\n * - the `np-color-scheme` cookie (so the server can render\n * the right initial attribute on the next request)\n * - localStorage (covers cookie loss in private mode etc.)\n *\n * Renders nothing until mounted to avoid a hydration mismatch\n * — the server doesn't know what the early-init script chose\n * for first-time visitors.\n */\nconst ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;\n\nfunction readCurrent(): NpColorScheme | null {\n if (typeof document === \"undefined\") return null;\n const attr = document.documentElement.dataset.theme;\n return isColorScheme(attr) ? attr : null;\n}\n\nfunction writeChoice(choice: NpColorScheme): void {\n document.documentElement.dataset.theme = choice;\n document.cookie = `${COLOR_SCHEME_COOKIE}=${choice}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`;\n try {\n window.localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, choice);\n } catch {\n // ignore — storage may be disabled\n }\n}\n\nexport function DarkModeToggle() {\n const [mounted, setMounted] = useState(false);\n const [scheme, setScheme] = useState<NpColorScheme | null>(null);\n\n useEffect(() => {\n setMounted(true);\n setScheme(readCurrent());\n }, []);\n\n if (!mounted) {\n // SSR + first paint: render a fixed-size placeholder so\n // the header layout doesn't reflow when the real button\n // mounts. `aria-hidden` because it's a visual stub.\n return (\n <span\n className=\"np-color-scheme-toggle np-color-scheme-toggle-placeholder\"\n aria-hidden=\"true\"\n />\n );\n }\n\n const next: NpColorScheme = scheme === \"dark\" ? \"light\" : \"dark\";\n const label = scheme === \"dark\" ? \"Switch to light mode\" : \"Switch to dark mode\";\n\n return (\n <button\n type=\"button\"\n className=\"np-color-scheme-toggle\"\n onClick={() => {\n writeChoice(next);\n setScheme(next);\n }}\n aria-label={label}\n title={label}\n >\n {scheme === \"dark\" ? <SunIcon /> : <MoonIcon />}\n </button>\n );\n}\n\nfunction SunIcon() {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"4\" />\n <path d=\"M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41\" />\n </svg>\n );\n}\n\nfunction MoonIcon() {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\" />\n </svg>\n );\n}\n"],"mappings":";;;;AAEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,WAAW,gBAAgB;AAiD9B,cA4BF,YA5BE;AAhCN,IAAM,mBAAmB,KAAK,KAAK,KAAK;AAExC,SAAS,cAAoC;AAC3C,MAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,QAAM,OAAO,SAAS,gBAAgB,QAAQ;AAC9C,SAAO,cAAc,IAAI,IAAI,OAAO;AACtC;AAEA,SAAS,YAAY,QAA6B;AAChD,WAAS,gBAAgB,QAAQ,QAAQ;AACzC,WAAS,SAAS,GAAG,mBAAmB,IAAI,MAAM,qBAAqB,gBAAgB;AACvF,MAAI;AACF,WAAO,aAAa,QAAQ,0BAA0B,MAAM;AAAA,EAC9D,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,iBAAiB;AAC/B,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA+B,IAAI;AAE/D,YAAU,MAAM;AACd,eAAW,IAAI;AACf,cAAU,YAAY,CAAC;AAAA,EACzB,GAAG,CAAC,CAAC;AAEL,MAAI,CAAC,SAAS;AAIZ,WACE;AAAA,MAAC;AAAA;AAAA,QACC,WAAU;AAAA,QACV,eAAY;AAAA;AAAA,IACd;AAAA,EAEJ;AAEA,QAAM,OAAsB,WAAW,SAAS,UAAU;AAC1D,QAAM,QAAQ,WAAW,SAAS,yBAAyB;AAE3D,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,WAAU;AAAA,MACV,SAAS,MAAM;AACb,oBAAY,IAAI;AAChB,kBAAU,IAAI;AAAA,MAChB;AAAA,MACA,cAAY;AAAA,MACZ,OAAO;AAAA,MAEN,qBAAW,SAAS,oBAAC,WAAQ,IAAK,oBAAC,YAAS;AAAA;AAAA,EAC/C;AAEJ;AAEA,SAAS,UAAU;AACjB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,OAAM;AAAA,MACN,OAAM;AAAA,MACN,QAAO;AAAA,MACP,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,QAAO;AAAA,MACP,aAAY;AAAA,MACZ,eAAc;AAAA,MACd,gBAAe;AAAA,MACf,eAAY;AAAA,MAEZ;AAAA,4BAAC,YAAO,IAAG,MAAK,IAAG,MAAK,GAAE,KAAI;AAAA,QAC9B,oBAAC,UAAK,GAAE,sHAAqH;AAAA;AAAA;AAAA,EAC/H;AAEJ;AAEA,SAAS,WAAW;AAClB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,OAAM;AAAA,MACN,OAAM;AAAA,MACN,QAAO;AAAA,MACP,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,QAAO;AAAA,MACP,aAAY;AAAA,MACZ,eAAc;AAAA,MACd,gBAAe;AAAA,MACf,eAAY;AAAA,MAEZ,8BAAC,UAAK,GAAE,mDAAkD;AAAA;AAAA,EAC5D;AAEJ;","names":[]}
@@ -0,0 +1,17 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ /**
4
+ * Production-grade footer with four columns:
5
+ *
6
+ * 1. Brand (logo + tagline + social)
7
+ * 2. Sitemap (the site's `footer` navigation menu)
8
+ * 3. Resources (links to /blog, /search, /sitemap.xml, /feed.xml)
9
+ * 4. Subscribe (newsletter form)
10
+ *
11
+ * All four are server-rendered except `NewsletterForm`, which
12
+ * needs a client island for submit handling. Columns collapse
13
+ * to a single column below ~640px (CSS-driven, no JS).
14
+ */
15
+ declare function DefaultFooter(): Promise<react_jsx_runtime.JSX.Element>;
16
+
17
+ export { DefaultFooter };
@@ -0,0 +1,156 @@
1
+ // src/components/footer-columns.tsx
2
+ import { getCachedNavigation } from "@nexpress/next";
3
+ import Link from "next/link";
4
+ import { NewsletterForm } from "./newsletter-form.js";
5
+
6
+ // src/components/social-links.tsx
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ function buildLinks() {
9
+ const links = [];
10
+ const env = process.env;
11
+ if (env.NP_SOCIAL_GITHUB) {
12
+ links.push({ href: env.NP_SOCIAL_GITHUB, label: "GitHub", Icon: GithubIcon });
13
+ }
14
+ if (env.NP_SOCIAL_TWITTER) {
15
+ links.push({ href: env.NP_SOCIAL_TWITTER, label: "Twitter / X", Icon: TwitterIcon });
16
+ }
17
+ if (env.NP_SOCIAL_LINKEDIN) {
18
+ links.push({ href: env.NP_SOCIAL_LINKEDIN, label: "LinkedIn", Icon: LinkedInIcon });
19
+ }
20
+ if (env.NP_SOCIAL_MASTODON) {
21
+ links.push({ href: env.NP_SOCIAL_MASTODON, label: "Mastodon", Icon: MastodonIcon });
22
+ }
23
+ if (env.NP_SOCIAL_EMAIL) {
24
+ const value = env.NP_SOCIAL_EMAIL.startsWith("mailto:") ? env.NP_SOCIAL_EMAIL : `mailto:${env.NP_SOCIAL_EMAIL}`;
25
+ links.push({ href: value, label: "Email", Icon: EmailIcon });
26
+ }
27
+ links.push({
28
+ href: env.NP_SOCIAL_RSS ?? "/feed.xml",
29
+ label: "RSS",
30
+ Icon: RssIcon
31
+ });
32
+ return links;
33
+ }
34
+ function SocialLinks() {
35
+ const links = buildLinks();
36
+ if (links.length === 0) return null;
37
+ return /* @__PURE__ */ jsx("ul", { className: "np-site-footer-social", children: links.map((link) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
38
+ "a",
39
+ {
40
+ href: link.href,
41
+ "aria-label": link.label,
42
+ rel: link.label === "Mastodon" ? "me noopener noreferrer" : "noopener noreferrer",
43
+ target: link.href.startsWith("mailto:") || link.href.startsWith("/") ? void 0 : "_blank",
44
+ children: /* @__PURE__ */ jsx(link.Icon, {})
45
+ }
46
+ ) }, link.href)) });
47
+ }
48
+ var ICON_PROPS = {
49
+ width: 18,
50
+ height: 18,
51
+ viewBox: "0 0 24 24",
52
+ fill: "none",
53
+ stroke: "currentColor",
54
+ strokeWidth: 1.8,
55
+ strokeLinecap: "round",
56
+ strokeLinejoin: "round",
57
+ "aria-hidden": true
58
+ };
59
+ function GithubIcon() {
60
+ return /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", ...ICON_PROPS, children: /* @__PURE__ */ jsx("path", { d: "M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" }) });
61
+ }
62
+ function TwitterIcon() {
63
+ return /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", ...ICON_PROPS, children: /* @__PURE__ */ jsx("path", { d: "M22 4.01c-1 .49-1.98.689-3 .99-1.121-1.265-2.783-1.335-4.38-.737S11.977 6.323 12 8v1c-3.245.083-6.135-1.395-8-4 0 0-4.182 7.433 4 11-1.872 1.247-3.739 2.088-6 2 3.308 1.803 6.913 2.423 10.034 1.517 3.58-1.04 6.522-3.723 7.651-7.742a13.84 13.84 0 0 0 .497-3.753c-.002-.249 1.51-2.772 1.818-4.013z" }) });
64
+ }
65
+ function LinkedInIcon() {
66
+ return /* @__PURE__ */ jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", ...ICON_PROPS, children: [
67
+ /* @__PURE__ */ jsx("path", { d: "M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-4 0v7h-4v-7a6 6 0 0 1 6-6z" }),
68
+ /* @__PURE__ */ jsx("rect", { x: "2", y: "9", width: "4", height: "12" }),
69
+ /* @__PURE__ */ jsx("circle", { cx: "4", cy: "4", r: "2" })
70
+ ] });
71
+ }
72
+ function MastodonIcon() {
73
+ return /* @__PURE__ */ jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", ...ICON_PROPS, children: [
74
+ /* @__PURE__ */ jsx("path", { d: "M21 12c0 4.97-4.03 7-9 7s-9-2.03-9-7V8a5 5 0 0 1 5-5h8a5 5 0 0 1 5 5v4z" }),
75
+ /* @__PURE__ */ jsx("path", { d: "M9 13V9.5a2.5 2.5 0 1 1 5 0V13" })
76
+ ] });
77
+ }
78
+ function EmailIcon() {
79
+ return /* @__PURE__ */ jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", ...ICON_PROPS, children: [
80
+ /* @__PURE__ */ jsx("path", { d: "M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" }),
81
+ /* @__PURE__ */ jsx("polyline", { points: "22,6 12,13 2,6" })
82
+ ] });
83
+ }
84
+ function RssIcon() {
85
+ return /* @__PURE__ */ jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", ...ICON_PROPS, children: [
86
+ /* @__PURE__ */ jsx("path", { d: "M4 11a9 9 0 0 1 9 9" }),
87
+ /* @__PURE__ */ jsx("path", { d: "M4 4a16 16 0 0 1 16 16" }),
88
+ /* @__PURE__ */ jsx("circle", { cx: "5", cy: "19", r: "1" })
89
+ ] });
90
+ }
91
+
92
+ // src/components/footer-columns.tsx
93
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
94
+ async function DefaultFooter() {
95
+ const footerNav = await getCachedNavigation("footer");
96
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
97
+ return /* @__PURE__ */ jsx2("footer", { className: "np-site-footer", children: /* @__PURE__ */ jsxs2("div", { className: "np-site-footer-inner", children: [
98
+ /* @__PURE__ */ jsxs2("div", { className: "np-site-footer-grid", children: [
99
+ /* @__PURE__ */ jsxs2("section", { className: "np-site-footer-col np-site-footer-brand", children: [
100
+ /* @__PURE__ */ jsx2(Link, { href: "/", className: "np-site-footer-logo", children: "NexPress" }),
101
+ /* @__PURE__ */ jsx2("p", { className: "np-site-footer-tagline", children: "The Next.js-native CMS for content-led teams." }),
102
+ /* @__PURE__ */ jsx2(SocialLinks, {})
103
+ ] }),
104
+ /* @__PURE__ */ jsxs2("section", { className: "np-site-footer-col", children: [
105
+ /* @__PURE__ */ jsx2("h2", { className: "np-site-footer-heading", children: "Sitemap" }),
106
+ /* @__PURE__ */ jsx2("ul", { className: "np-site-footer-links", children: footerNav.length > 0 ? footerNav.map((item, index) => /* @__PURE__ */ jsxs2("li", { children: [
107
+ item.url ? /* @__PURE__ */ jsx2(Link, { href: item.url, children: item.label }) : /* @__PURE__ */ jsx2("span", { children: item.label }),
108
+ item.children && item.children.length > 0 ? /* @__PURE__ */ jsx2("ul", { className: "np-site-footer-subnav", children: item.children.map((child, childIndex) => /* @__PURE__ */ jsx2(
109
+ "li",
110
+ {
111
+ children: child.url ? /* @__PURE__ */ jsx2(Link, { href: child.url, children: child.label }) : /* @__PURE__ */ jsx2("span", { children: child.label })
112
+ },
113
+ `footer-sitemap-${index.toString()}-${childIndex.toString()}`
114
+ )) }) : null
115
+ ] }, `footer-sitemap-${index.toString()}`)) : /* @__PURE__ */ jsx2(FooterNavFallback, {}) })
116
+ ] }),
117
+ /* @__PURE__ */ jsxs2("section", { className: "np-site-footer-col", children: [
118
+ /* @__PURE__ */ jsx2("h2", { className: "np-site-footer-heading", children: "Resources" }),
119
+ /* @__PURE__ */ jsxs2("ul", { className: "np-site-footer-links", children: [
120
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/blog", children: "Blog" }) }),
121
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/search", children: "Search" }) }),
122
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2("a", { href: "/feed.xml", children: "RSS feed" }) }),
123
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2("a", { href: "/sitemap.xml", children: "Sitemap.xml" }) })
124
+ ] })
125
+ ] }),
126
+ /* @__PURE__ */ jsxs2("section", { className: "np-site-footer-col np-site-footer-subscribe", children: [
127
+ /* @__PURE__ */ jsx2("h2", { className: "np-site-footer-heading", children: "Subscribe" }),
128
+ /* @__PURE__ */ jsx2("p", { className: "np-site-footer-subscribe-blurb", children: "Occasional updates. No spam." }),
129
+ /* @__PURE__ */ jsx2(NewsletterForm, {})
130
+ ] })
131
+ ] }),
132
+ /* @__PURE__ */ jsxs2("div", { className: "np-site-footer-bottom", children: [
133
+ /* @__PURE__ */ jsxs2("p", { className: "np-site-footer-copy", children: [
134
+ "\xA9 ",
135
+ year.toString(),
136
+ " \xB7 Built with NexPress"
137
+ ] }),
138
+ /* @__PURE__ */ jsxs2("ul", { className: "np-site-footer-meta", children: [
139
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/privacy", children: "Privacy" }) }),
140
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/terms", children: "Terms" }) })
141
+ ] })
142
+ ] })
143
+ ] }) });
144
+ }
145
+ function FooterNavFallback() {
146
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
147
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/", children: "Home" }) }),
148
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/about", children: "About" }) }),
149
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/blog", children: "Blog" }) }),
150
+ /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(Link, { href: "/contact", children: "Contact" }) })
151
+ ] });
152
+ }
153
+ export {
154
+ DefaultFooter
155
+ };
156
+ //# sourceMappingURL=footer-columns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/footer-columns.tsx","../../src/components/social-links.tsx"],"sourcesContent":["import type { NpNavItem } from \"@nexpress/core\";\nimport { getCachedNavigation } from \"@nexpress/next\";\nimport Link from \"next/link\";\n\nimport { NewsletterForm } from \"./newsletter-form.js\";\nimport { SocialLinks } from \"./social-links.js\";\n\n/**\n * Production-grade footer with four columns:\n *\n * 1. Brand (logo + tagline + social)\n * 2. Sitemap (the site's `footer` navigation menu)\n * 3. Resources (links to /blog, /search, /sitemap.xml, /feed.xml)\n * 4. Subscribe (newsletter form)\n *\n * All four are server-rendered except `NewsletterForm`, which\n * needs a client island for submit handling. Columns collapse\n * to a single column below ~640px (CSS-driven, no JS).\n */\nexport async function DefaultFooter() {\n const footerNav = await getCachedNavigation(\"footer\");\n const year = new Date().getFullYear();\n\n return (\n <footer className=\"np-site-footer\">\n <div className=\"np-site-footer-inner\">\n <div className=\"np-site-footer-grid\">\n <section className=\"np-site-footer-col np-site-footer-brand\">\n <Link href=\"/\" className=\"np-site-footer-logo\">\n NexPress\n </Link>\n <p className=\"np-site-footer-tagline\">\n The Next.js-native CMS for content-led teams.\n </p>\n <SocialLinks />\n </section>\n\n <section className=\"np-site-footer-col\">\n <h2 className=\"np-site-footer-heading\">Sitemap</h2>\n <ul className=\"np-site-footer-links\">\n {footerNav.length > 0 ? (\n footerNav.map((item: NpNavItem, index: number) => (\n <li key={`footer-sitemap-${index.toString()}`}>\n {item.url ? <Link href={item.url}>{item.label}</Link> : <span>{item.label}</span>}\n {item.children && item.children.length > 0 ? (\n <ul className=\"np-site-footer-subnav\">\n {item.children.map((child: NpNavItem, childIndex: number) => (\n <li\n key={`footer-sitemap-${index.toString()}-${childIndex.toString()}`}\n >\n {child.url ? <Link href={child.url}>{child.label}</Link> : <span>{child.label}</span>}\n </li>\n ))}\n </ul>\n ) : null}\n </li>\n ))\n ) : (\n <FooterNavFallback />\n )}\n </ul>\n </section>\n\n <section className=\"np-site-footer-col\">\n <h2 className=\"np-site-footer-heading\">Resources</h2>\n <ul className=\"np-site-footer-links\">\n <li>\n <Link href=\"/blog\">Blog</Link>\n </li>\n <li>\n <Link href=\"/search\">Search</Link>\n </li>\n <li>\n <a href=\"/feed.xml\">RSS feed</a>\n </li>\n <li>\n <a href=\"/sitemap.xml\">Sitemap.xml</a>\n </li>\n </ul>\n </section>\n\n <section className=\"np-site-footer-col np-site-footer-subscribe\">\n <h2 className=\"np-site-footer-heading\">Subscribe</h2>\n <p className=\"np-site-footer-subscribe-blurb\">\n Occasional updates. No spam.\n </p>\n <NewsletterForm />\n </section>\n </div>\n\n <div className=\"np-site-footer-bottom\">\n <p className=\"np-site-footer-copy\">\n © {year.toString()} · Built with NexPress\n </p>\n <ul className=\"np-site-footer-meta\">\n <li>\n <Link href=\"/privacy\">Privacy</Link>\n </li>\n <li>\n <Link href=\"/terms\">Terms</Link>\n </li>\n </ul>\n </div>\n </div>\n </footer>\n );\n}\n\nfunction FooterNavFallback() {\n // Keeps the column from looking empty on a fresh install\n // before the operator wires up a footer menu in /admin.\n return (\n <>\n <li>\n <Link href=\"/\">Home</Link>\n </li>\n <li>\n <Link href=\"/about\">About</Link>\n </li>\n <li>\n <Link href=\"/blog\">Blog</Link>\n </li>\n <li>\n <Link href=\"/contact\">Contact</Link>\n </li>\n </>\n );\n}\n","/**\n * Social-link strip read from `process.env.NP_SOCIAL_*` so\n * sites can light up the footer icons without forking the theme.\n * Empty when no env vars are set — the column collapses cleanly.\n *\n * Recognized env vars:\n * NP_SOCIAL_GITHUB → https://github.com/<handle>\n * NP_SOCIAL_TWITTER → https://twitter.com/<handle> or x.com URL\n * NP_SOCIAL_LINKEDIN → company / personal URL\n * NP_SOCIAL_MASTODON → https://mastodon.social/@<handle>\n * NP_SOCIAL_RSS → defaults to /feed.xml; set explicit URL to override\n * NP_SOCIAL_EMAIL → mailto: address\n */\n\ninterface SocialLink {\n href: string;\n label: string;\n Icon: () => React.JSX.Element;\n}\n\nfunction buildLinks(): SocialLink[] {\n const links: SocialLink[] = [];\n const env = process.env;\n if (env.NP_SOCIAL_GITHUB) {\n links.push({ href: env.NP_SOCIAL_GITHUB, label: \"GitHub\", Icon: GithubIcon });\n }\n if (env.NP_SOCIAL_TWITTER) {\n links.push({ href: env.NP_SOCIAL_TWITTER, label: \"Twitter / X\", Icon: TwitterIcon });\n }\n if (env.NP_SOCIAL_LINKEDIN) {\n links.push({ href: env.NP_SOCIAL_LINKEDIN, label: \"LinkedIn\", Icon: LinkedInIcon });\n }\n if (env.NP_SOCIAL_MASTODON) {\n // Mastodon recommends rel=\"me\" for verified profile links.\n links.push({ href: env.NP_SOCIAL_MASTODON, label: \"Mastodon\", Icon: MastodonIcon });\n }\n if (env.NP_SOCIAL_EMAIL) {\n const value = env.NP_SOCIAL_EMAIL.startsWith(\"mailto:\")\n ? env.NP_SOCIAL_EMAIL\n : `mailto:${env.NP_SOCIAL_EMAIL}`;\n links.push({ href: value, label: \"Email\", Icon: EmailIcon });\n }\n // RSS is always useful — default to the framework's feed when\n // not overridden. The icon is the universal RSS mark.\n links.push({\n href: env.NP_SOCIAL_RSS ?? \"/feed.xml\",\n label: \"RSS\",\n Icon: RssIcon,\n });\n return links;\n}\n\nexport function SocialLinks() {\n const links = buildLinks();\n if (links.length === 0) return null;\n return (\n <ul className=\"np-site-footer-social\">\n {links.map((link) => (\n <li key={link.href}>\n <a\n href={link.href}\n aria-label={link.label}\n rel={link.label === \"Mastodon\" ? \"me noopener noreferrer\" : \"noopener noreferrer\"}\n target={link.href.startsWith(\"mailto:\") || link.href.startsWith(\"/\") ? undefined : \"_blank\"}\n >\n <link.Icon />\n </a>\n </li>\n ))}\n </ul>\n );\n}\n\nconst ICON_PROPS = {\n width: 18,\n height: 18,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 1.8,\n strokeLinecap: \"round\" as const,\n strokeLinejoin: \"round\" as const,\n \"aria-hidden\": true,\n};\n\nfunction GithubIcon() {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" {...ICON_PROPS}>\n <path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22\" />\n </svg>\n );\n}\n\nfunction TwitterIcon() {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" {...ICON_PROPS}>\n <path d=\"M22 4.01c-1 .49-1.98.689-3 .99-1.121-1.265-2.783-1.335-4.38-.737S11.977 6.323 12 8v1c-3.245.083-6.135-1.395-8-4 0 0-4.182 7.433 4 11-1.872 1.247-3.739 2.088-6 2 3.308 1.803 6.913 2.423 10.034 1.517 3.58-1.04 6.522-3.723 7.651-7.742a13.84 13.84 0 0 0 .497-3.753c-.002-.249 1.51-2.772 1.818-4.013z\" />\n </svg>\n );\n}\n\nfunction LinkedInIcon() {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" {...ICON_PROPS}>\n <path d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-4 0v7h-4v-7a6 6 0 0 1 6-6z\" />\n <rect x=\"2\" y=\"9\" width=\"4\" height=\"12\" />\n <circle cx=\"4\" cy=\"4\" r=\"2\" />\n </svg>\n );\n}\n\nfunction MastodonIcon() {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" {...ICON_PROPS}>\n <path d=\"M21 12c0 4.97-4.03 7-9 7s-9-2.03-9-7V8a5 5 0 0 1 5-5h8a5 5 0 0 1 5 5v4z\" />\n <path d=\"M9 13V9.5a2.5 2.5 0 1 1 5 0V13\" />\n </svg>\n );\n}\n\nfunction EmailIcon() {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" {...ICON_PROPS}>\n <path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\" />\n <polyline points=\"22,6 12,13 2,6\" />\n </svg>\n );\n}\n\nfunction RssIcon() {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" {...ICON_PROPS}>\n <path d=\"M4 11a9 9 0 0 1 9 9\" />\n <path d=\"M4 4a16 16 0 0 1 16 16\" />\n <circle cx=\"5\" cy=\"19\" r=\"1\" />\n </svg>\n );\n}\n"],"mappings":"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","names":["jsx","jsxs"]}
@@ -0,0 +1,39 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ /**
4
+ * Phase 12.6 — visitor-facing language picker for i18n sites.
5
+ *
6
+ * The locale list is passed in as a prop by the (server)
7
+ * header that reads `getI18nConfig()`. Rendering a `<Link>`
8
+ * for each configured locale lets the visitor jump to the
9
+ * same path under a different locale prefix; the path's first
10
+ * segment is replaced when it matches a known locale, or the
11
+ * locale prefix is added when it doesn't.
12
+ *
13
+ * Sibling-aware mode (Sprint S, doc i18n.md §13): the server
14
+ * resolves which locales actually publish a translation of the
15
+ * current page and passes the result via `availableLocales`.
16
+ * Locales not in that set render as a disabled `<span>` with
17
+ * `aria-disabled="true"` so the visitor can't jump to a
18
+ * guaranteed 404. When the prop is omitted the picker keeps
19
+ * its original "every locale is a live link" behavior — that's
20
+ * the right default for static paths (`/`, `/blog`, `/search`)
21
+ * where the URL space exists in every locale via the catch-all.
22
+ */
23
+ interface LanguagePickerProps {
24
+ locales: string[];
25
+ /**
26
+ * Optional subset of `locales` that actually has a published
27
+ * translation of the current page. Locales outside this set
28
+ * render disabled. Pass `undefined` to leave every locale
29
+ * enabled (the pre-Sprint-S behavior).
30
+ */
31
+ availableLocales?: readonly string[];
32
+ /** Optional override for displaying the locale chip (e.g.
33
+ * uppercase code, native name). Defaults to the locale
34
+ * string upper-cased. */
35
+ formatLabel?: (locale: string) => string;
36
+ }
37
+ declare function LanguagePicker({ locales, availableLocales, formatLabel, }: LanguagePickerProps): react_jsx_runtime.JSX.Element;
38
+
39
+ export { LanguagePicker, type LanguagePickerProps };
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/components/language-picker.tsx
5
+ import Link from "next/link";
6
+ import { usePathname } from "next/navigation";
7
+ import { jsx } from "react/jsx-runtime";
8
+ function LanguagePicker({
9
+ locales,
10
+ availableLocales,
11
+ formatLabel = (locale) => locale.toUpperCase()
12
+ }) {
13
+ const pathname = usePathname();
14
+ const segments = (pathname ?? "/").split("/").filter(Boolean);
15
+ const currentLocale = segments[0] && locales.includes(segments[0]) ? segments[0] : null;
16
+ const remainder = currentLocale ? segments.slice(1).join("/") : segments.join("/");
17
+ const availableSet = availableLocales ? new Set(availableLocales) : null;
18
+ return /* @__PURE__ */ jsx("nav", { className: "np-language-picker", "aria-label": "Language", children: locales.map((locale) => {
19
+ const href = remainder.length > 0 ? `/${locale}/${remainder}` : `/${locale}`;
20
+ const isActive = locale === currentLocale;
21
+ const isAvailable = availableSet ? availableSet.has(locale) : true;
22
+ if (!isAvailable) {
23
+ return /* @__PURE__ */ jsx(
24
+ "span",
25
+ {
26
+ className: "np-language-picker-link",
27
+ "aria-disabled": "true",
28
+ "data-disabled": "true",
29
+ title: "No translation available for this page",
30
+ children: formatLabel(locale)
31
+ },
32
+ locale
33
+ );
34
+ }
35
+ return /* @__PURE__ */ jsx(
36
+ Link,
37
+ {
38
+ href,
39
+ className: "np-language-picker-link",
40
+ hrefLang: locale,
41
+ "aria-current": isActive ? "true" : void 0,
42
+ "data-active": isActive ? "true" : void 0,
43
+ children: formatLabel(locale)
44
+ },
45
+ locale
46
+ );
47
+ }) });
48
+ }
49
+ export {
50
+ LanguagePicker
51
+ };
52
+ //# sourceMappingURL=language-picker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/language-picker.tsx"],"sourcesContent":["\"use client\";\n\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\n\n/**\n * Phase 12.6 — visitor-facing language picker for i18n sites.\n *\n * The locale list is passed in as a prop by the (server)\n * header that reads `getI18nConfig()`. Rendering a `<Link>`\n * for each configured locale lets the visitor jump to the\n * same path under a different locale prefix; the path's first\n * segment is replaced when it matches a known locale, or the\n * locale prefix is added when it doesn't.\n *\n * Sibling-aware mode (Sprint S, doc i18n.md §13): the server\n * resolves which locales actually publish a translation of the\n * current page and passes the result via `availableLocales`.\n * Locales not in that set render as a disabled `<span>` with\n * `aria-disabled=\"true\"` so the visitor can't jump to a\n * guaranteed 404. When the prop is omitted the picker keeps\n * its original \"every locale is a live link\" behavior — that's\n * the right default for static paths (`/`, `/blog`, `/search`)\n * where the URL space exists in every locale via the catch-all.\n */\nexport interface LanguagePickerProps {\n locales: string[];\n /**\n * Optional subset of `locales` that actually has a published\n * translation of the current page. Locales outside this set\n * render disabled. Pass `undefined` to leave every locale\n * enabled (the pre-Sprint-S behavior).\n */\n availableLocales?: readonly string[];\n /** Optional override for displaying the locale chip (e.g.\n * uppercase code, native name). Defaults to the locale\n * string upper-cased. */\n formatLabel?: (locale: string) => string;\n}\n\nexport function LanguagePicker({\n locales,\n availableLocales,\n formatLabel = (locale) => locale.toUpperCase(),\n}: LanguagePickerProps) {\n const pathname = usePathname();\n\n // The pathname always starts with `/`. Splitting and\n // filtering empty strings yields the segments. If the first\n // segment matches a known locale we strip it; otherwise we\n // treat the whole path as the locale-less remainder.\n const segments = (pathname ?? \"/\").split(\"/\").filter(Boolean);\n const currentLocale = segments[0] && locales.includes(segments[0]) ? segments[0] : null;\n const remainder = currentLocale ? segments.slice(1).join(\"/\") : segments.join(\"/\");\n\n const availableSet = availableLocales ? new Set(availableLocales) : null;\n\n return (\n <nav className=\"np-language-picker\" aria-label=\"Language\">\n {locales.map((locale) => {\n const href = remainder.length > 0 ? `/${locale}/${remainder}` : `/${locale}`;\n const isActive = locale === currentLocale;\n const isAvailable = availableSet ? availableSet.has(locale) : true;\n if (!isAvailable) {\n return (\n <span\n key={locale}\n className=\"np-language-picker-link\"\n aria-disabled=\"true\"\n data-disabled=\"true\"\n title=\"No translation available for this page\"\n >\n {formatLabel(locale)}\n </span>\n );\n }\n return (\n <Link\n key={locale}\n href={href}\n className=\"np-language-picker-link\"\n hrefLang={locale}\n aria-current={isActive ? \"true\" : undefined}\n data-active={isActive ? \"true\" : undefined}\n >\n {formatLabel(locale)}\n </Link>\n );\n })}\n </nav>\n );\n}\n"],"mappings":";;;;AAEA,OAAO,UAAU;AACjB,SAAS,mBAAmB;AA8DhB;AAzBL,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA,cAAc,CAAC,WAAW,OAAO,YAAY;AAC/C,GAAwB;AACtB,QAAM,WAAW,YAAY;AAM7B,QAAM,YAAY,YAAY,KAAK,MAAM,GAAG,EAAE,OAAO,OAAO;AAC5D,QAAM,gBAAgB,SAAS,CAAC,KAAK,QAAQ,SAAS,SAAS,CAAC,CAAC,IAAI,SAAS,CAAC,IAAI;AACnF,QAAM,YAAY,gBAAgB,SAAS,MAAM,CAAC,EAAE,KAAK,GAAG,IAAI,SAAS,KAAK,GAAG;AAEjF,QAAM,eAAe,mBAAmB,IAAI,IAAI,gBAAgB,IAAI;AAEpE,SACE,oBAAC,SAAI,WAAU,sBAAqB,cAAW,YAC5C,kBAAQ,IAAI,CAAC,WAAW;AACvB,UAAM,OAAO,UAAU,SAAS,IAAI,IAAI,MAAM,IAAI,SAAS,KAAK,IAAI,MAAM;AAC1E,UAAM,WAAW,WAAW;AAC5B,UAAM,cAAc,eAAe,aAAa,IAAI,MAAM,IAAI;AAC9D,QAAI,CAAC,aAAa;AAChB,aACE;AAAA,QAAC;AAAA;AAAA,UAEC,WAAU;AAAA,UACV,iBAAc;AAAA,UACd,iBAAc;AAAA,UACd,OAAM;AAAA,UAEL,sBAAY,MAAM;AAAA;AAAA,QANd;AAAA,MAOP;AAAA,IAEJ;AACA,WACE;AAAA,MAAC;AAAA;AAAA,QAEC;AAAA,QACA,WAAU;AAAA,QACV,UAAU;AAAA,QACV,gBAAc,WAAW,SAAS;AAAA,QAClC,eAAa,WAAW,SAAS;AAAA,QAEhC,sBAAY,MAAM;AAAA;AAAA,MAPd;AAAA,IAQP;AAAA,EAEJ,CAAC,GACH;AAEJ;","names":[]}
@@ -0,0 +1,17 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ /**
4
+ * Site header widget that probes `/api/members/me` on mount and
5
+ * renders either signed-in (`@handle` + Sign out) or anonymous
6
+ * (Sign in / Register links). Phase 11.2 moved this from
7
+ * `apps/web/components` into the theme package — it's shipped
8
+ * as a default-theme detail, not framework-level chrome, so
9
+ * a theme that wants a different auth UX can omit or replace it.
10
+ *
11
+ * Client-side detection avoids re-rendering the entire (site)
12
+ * layout when auth state changes — the existing pattern used
13
+ * by the `<Comments />` and `<FollowButton />` components.
14
+ */
15
+ declare function MemberStatusWidget(): react_jsx_runtime.JSX.Element;
16
+
17
+ export { MemberStatusWidget };
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/components/member-status-widget.tsx
5
+ import Link from "next/link";
6
+ import { useRouter } from "next/navigation";
7
+ import { useEffect, useState } from "react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ function MemberStatusWidget() {
10
+ const router = useRouter();
11
+ const [member, setMember] = useState("loading");
12
+ const [signingOut, setSigningOut] = useState(false);
13
+ useEffect(() => {
14
+ void (async () => {
15
+ try {
16
+ const res = await fetch("/api/members/me", { credentials: "include" });
17
+ if (!res.ok) {
18
+ setMember(null);
19
+ return;
20
+ }
21
+ const body = await res.json().catch(() => null);
22
+ const m = body?.data?.member ?? body?.member ?? null;
23
+ setMember(m && m.id ? m : null);
24
+ } catch {
25
+ setMember(null);
26
+ }
27
+ })();
28
+ }, []);
29
+ const onSignOut = async () => {
30
+ setSigningOut(true);
31
+ try {
32
+ await fetch("/api/members/logout", {
33
+ method: "POST",
34
+ credentials: "include"
35
+ });
36
+ } catch {
37
+ }
38
+ setMember(null);
39
+ setSigningOut(false);
40
+ router.push("/");
41
+ router.refresh();
42
+ };
43
+ if (member === "loading") {
44
+ return /* @__PURE__ */ jsx(
45
+ "span",
46
+ {
47
+ className: "np-member-status np-member-status-loading",
48
+ "aria-hidden": "true"
49
+ }
50
+ );
51
+ }
52
+ if (member) {
53
+ return /* @__PURE__ */ jsxs("div", { className: "np-member-status", children: [
54
+ /* @__PURE__ */ jsxs(Link, { href: `/u/${member.handle}`, className: "np-member-status-handle", children: [
55
+ "@",
56
+ member.handle
57
+ ] }),
58
+ /* @__PURE__ */ jsx(
59
+ "button",
60
+ {
61
+ type: "button",
62
+ className: "np-text-button",
63
+ onClick: () => void onSignOut(),
64
+ disabled: signingOut,
65
+ children: signingOut ? "Signing out\u2026" : "Sign out"
66
+ }
67
+ )
68
+ ] });
69
+ }
70
+ return /* @__PURE__ */ jsxs("div", { className: "np-member-status", children: [
71
+ /* @__PURE__ */ jsx(Link, { href: "/members/login", children: "Sign in" }),
72
+ /* @__PURE__ */ jsx(Link, { href: "/members/register", className: "np-button-primary", children: "Register" })
73
+ ] });
74
+ }
75
+ export {
76
+ MemberStatusWidget
77
+ };
78
+ //# sourceMappingURL=member-status-widget.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/member-status-widget.tsx"],"sourcesContent":["\"use client\";\n\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\n\ninterface MemberMe {\n id: string;\n handle: string;\n displayName: string;\n}\n\n/**\n * Site header widget that probes `/api/members/me` on mount and\n * renders either signed-in (`@handle` + Sign out) or anonymous\n * (Sign in / Register links). Phase 11.2 moved this from\n * `apps/web/components` into the theme package — it's shipped\n * as a default-theme detail, not framework-level chrome, so\n * a theme that wants a different auth UX can omit or replace it.\n *\n * Client-side detection avoids re-rendering the entire (site)\n * layout when auth state changes — the existing pattern used\n * by the `<Comments />` and `<FollowButton />` components.\n */\nexport function MemberStatusWidget() {\n const router = useRouter();\n const [member, setMember] = useState<MemberMe | null | \"loading\">(\"loading\");\n const [signingOut, setSigningOut] = useState(false);\n\n useEffect(() => {\n void (async () => {\n try {\n const res = await fetch(\"/api/members/me\", { credentials: \"include\" });\n if (!res.ok) {\n setMember(null);\n return;\n }\n const body = (await res.json().catch(() => null)) as\n | { data?: { member?: MemberMe }; member?: MemberMe }\n | null;\n const m = body?.data?.member ?? body?.member ?? null;\n setMember(m && m.id ? m : null);\n } catch {\n setMember(null);\n }\n })();\n }, []);\n\n const onSignOut = async () => {\n setSigningOut(true);\n try {\n await fetch(\"/api/members/logout\", {\n method: \"POST\",\n credentials: \"include\",\n });\n } catch {\n // Ignore — local state still clears below; the cookie\n // typically expires server-side regardless.\n }\n setMember(null);\n setSigningOut(false);\n router.push(\"/\");\n router.refresh();\n };\n\n if (member === \"loading\") {\n return (\n <span\n className=\"np-member-status np-member-status-loading\"\n aria-hidden=\"true\"\n />\n );\n }\n\n if (member) {\n return (\n <div className=\"np-member-status\">\n <Link href={`/u/${member.handle}`} className=\"np-member-status-handle\">\n @{member.handle}\n </Link>\n <button\n type=\"button\"\n className=\"np-text-button\"\n onClick={() => void onSignOut()}\n disabled={signingOut}\n >\n {signingOut ? \"Signing out…\" : \"Sign out\"}\n </button>\n </div>\n );\n }\n\n return (\n <div className=\"np-member-status\">\n <Link href=\"/members/login\">Sign in</Link>\n <Link href=\"/members/register\" className=\"np-button-primary\">\n Register\n </Link>\n </div>\n );\n}\n"],"mappings":";;;;AAEA,OAAO,UAAU;AACjB,SAAS,iBAAiB;AAC1B,SAAS,WAAW,gBAAgB;AA+D9B,cAUE,YAVF;AA3CC,SAAS,qBAAqB;AACnC,QAAM,SAAS,UAAU;AACzB,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAsC,SAAS;AAC3E,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,KAAK;AAElD,YAAU,MAAM;AACd,UAAM,YAAY;AAChB,UAAI;AACF,cAAM,MAAM,MAAM,MAAM,mBAAmB,EAAE,aAAa,UAAU,CAAC;AACrE,YAAI,CAAC,IAAI,IAAI;AACX,oBAAU,IAAI;AACd;AAAA,QACF;AACA,cAAM,OAAQ,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAG/C,cAAM,IAAI,MAAM,MAAM,UAAU,MAAM,UAAU;AAChD,kBAAU,KAAK,EAAE,KAAK,IAAI,IAAI;AAAA,MAChC,QAAQ;AACN,kBAAU,IAAI;AAAA,MAChB;AAAA,IACF,GAAG;AAAA,EACL,GAAG,CAAC,CAAC;AAEL,QAAM,YAAY,YAAY;AAC5B,kBAAc,IAAI;AAClB,QAAI;AACF,YAAM,MAAM,uBAAuB;AAAA,QACjC,QAAQ;AAAA,QACR,aAAa;AAAA,MACf,CAAC;AAAA,IACH,QAAQ;AAAA,IAGR;AACA,cAAU,IAAI;AACd,kBAAc,KAAK;AACnB,WAAO,KAAK,GAAG;AACf,WAAO,QAAQ;AAAA,EACjB;AAEA,MAAI,WAAW,WAAW;AACxB,WACE;AAAA,MAAC;AAAA;AAAA,QACC,WAAU;AAAA,QACV,eAAY;AAAA;AAAA,IACd;AAAA,EAEJ;AAEA,MAAI,QAAQ;AACV,WACE,qBAAC,SAAI,WAAU,oBACb;AAAA,2BAAC,QAAK,MAAM,MAAM,OAAO,MAAM,IAAI,WAAU,2BAA0B;AAAA;AAAA,QACnE,OAAO;AAAA,SACX;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,SAAS,MAAM,KAAK,UAAU;AAAA,UAC9B,UAAU;AAAA,UAET,uBAAa,sBAAiB;AAAA;AAAA,MACjC;AAAA,OACF;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAU,oBACb;AAAA,wBAAC,QAAK,MAAK,kBAAiB,qBAAO;AAAA,IACnC,oBAAC,QAAK,MAAK,qBAAoB,WAAU,qBAAoB,sBAE7D;AAAA,KACF;AAEJ;","names":[]}
@@ -0,0 +1,23 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { NpNavItem } from '@nexpress/core';
3
+
4
+ /**
5
+ * Mobile-first nav drawer. The desktop header keeps its inline
6
+ * link list visible above ~768px (CSS handles the hide/show);
7
+ * below that breakpoint the inline nav is hidden by CSS and the
8
+ * hamburger button + slide-in drawer take over.
9
+ *
10
+ * Why a client component: the drawer needs `useState` for
11
+ * open/closed, focus-trap on Escape, and scroll-lock on the
12
+ * body. The link list itself is passed in from the server-
13
+ * rendered header so the markup stays SEO-visible even when
14
+ * the drawer is closed.
15
+ */
16
+ interface MobileNavProps {
17
+ items: NpNavItem[];
18
+ /** Optional brand label for the drawer header. Defaults to "Menu". */
19
+ label?: string;
20
+ }
21
+ declare function MobileNav({ items, label }: MobileNavProps): react_jsx_runtime.JSX.Element;
22
+
23
+ export { MobileNav, type MobileNavProps };