@supatent/supatent-docs 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/dist/chunk-QVZFIUSH.js +13777 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +75 -0
- package/dist/next-config.d.ts +20 -0
- package/dist/next-config.js +305 -0
- package/dist/types-Cf4pRODK.d.ts +60 -0
- package/package.json +49 -0
- package/runtime/middleware.ts +52 -0
- package/runtime/src/app/[locale]/docs-internal/[[...slug]]/page.tsx +91 -0
- package/runtime/src/app/[locale]/docs-internal/ai/all/route.ts +52 -0
- package/runtime/src/app/[locale]/docs-internal/ai/index/route.ts +52 -0
- package/runtime/src/app/[locale]/docs-internal/markdown/[...slug]/route.ts +50 -0
- package/runtime/src/app/globals.css +1082 -0
- package/runtime/src/app/layout.tsx +37 -0
- package/runtime/src/app/page.tsx +26 -0
- package/runtime/src/components/code-group-enhancer.tsx +128 -0
- package/runtime/src/components/docs-shell.tsx +140 -0
- package/runtime/src/components/header-dropdown.tsx +138 -0
- package/runtime/src/components/icons.tsx +58 -0
- package/runtime/src/components/locale-switcher.tsx +97 -0
- package/runtime/src/components/mobile-docs-menu.tsx +208 -0
- package/runtime/src/components/site-header.tsx +44 -0
- package/runtime/src/components/site-search.tsx +358 -0
- package/runtime/src/components/theme-toggle.tsx +74 -0
- package/runtime/src/docs.config.ts +91 -0
- package/runtime/src/framework/config.ts +76 -0
- package/runtime/src/framework/errors.ts +34 -0
- package/runtime/src/framework/locales.ts +45 -0
- package/runtime/src/framework/markdown-search-text.ts +11 -0
- package/runtime/src/framework/navigation.ts +58 -0
- package/runtime/src/framework/next-config.ts +160 -0
- package/runtime/src/framework/rendering.ts +445 -0
- package/runtime/src/framework/repository.ts +255 -0
- package/runtime/src/framework/runtime-locales.ts +85 -0
- package/runtime/src/framework/search-index-types.ts +34 -0
- package/runtime/src/framework/search-index.ts +271 -0
- package/runtime/src/framework/service.ts +302 -0
- package/runtime/src/framework/settings.ts +43 -0
- package/runtime/src/framework/site-title.ts +54 -0
- package/runtime/src/framework/theme.ts +17 -0
- package/runtime/src/framework/types.ts +66 -0
- package/runtime/src/framework/url.ts +78 -0
- package/runtime/src/supatent/client.ts +2 -0
- package/src/index.ts +11 -0
- package/src/next-config.ts +5 -0
- package/src/next.ts +10 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
import { SiteHeader } from "../components/site-header";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Supatent Docs Framework",
|
|
7
|
+
description: "SSR documentation framework powered by Supatent CMS",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const themeScript = `
|
|
11
|
+
(() => {
|
|
12
|
+
try {
|
|
13
|
+
const stored = localStorage.getItem("docs-theme");
|
|
14
|
+
const system = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
15
|
+
const theme = stored === "dark" || stored === "light" ? stored : system;
|
|
16
|
+
document.documentElement.dataset.theme = theme;
|
|
17
|
+
} catch {
|
|
18
|
+
document.documentElement.dataset.theme = "light";
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export default function RootLayout({
|
|
24
|
+
children,
|
|
25
|
+
}: Readonly<{ children: React.ReactNode }>) {
|
|
26
|
+
return (
|
|
27
|
+
<html lang="en" suppressHydrationWarning>
|
|
28
|
+
<head>
|
|
29
|
+
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<SiteHeader />
|
|
33
|
+
{children}
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { docsConfig } from "../docs.config";
|
|
2
|
+
import { toDocsPagePath } from "../framework/url";
|
|
3
|
+
|
|
4
|
+
export default function HomePage() {
|
|
5
|
+
const firstLocale = docsConfig.routing.defaultLocale;
|
|
6
|
+
const docsPath = toDocsPagePath(docsConfig, firstLocale);
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<main className="home-wrap">
|
|
10
|
+
<section className="home-card">
|
|
11
|
+
<h1>Supatent Docs Framework</h1>
|
|
12
|
+
<p>
|
|
13
|
+
Framework is configured. Start with{" "}
|
|
14
|
+
<a href={docsPath}>
|
|
15
|
+
<code>{docsPath}</code>
|
|
16
|
+
</a>
|
|
17
|
+
.
|
|
18
|
+
</p>
|
|
19
|
+
<p>
|
|
20
|
+
Required env var: <code>SUPATENT_PROJECT_SLUG</code>.{" "}
|
|
21
|
+
<code>SUPATENT_API_KEY</code> is required only in draft mode.
|
|
22
|
+
</p>
|
|
23
|
+
</section>
|
|
24
|
+
</main>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
type CleanupFn = () => void;
|
|
6
|
+
|
|
7
|
+
function parseIndex(value: string | null): number {
|
|
8
|
+
if (!value) {
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const parsed = Number.parseInt(value, 10);
|
|
13
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CodeGroupEnhancer() {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const cleanups: CleanupFn[] = [];
|
|
19
|
+
const groups = Array.from(
|
|
20
|
+
document.querySelectorAll<HTMLElement>(".docs-code-group"),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
for (const group of groups) {
|
|
24
|
+
const tabs = Array.from(
|
|
25
|
+
group.querySelectorAll<HTMLButtonElement>("[data-code-tab]"),
|
|
26
|
+
);
|
|
27
|
+
const panels = Array.from(
|
|
28
|
+
group.querySelectorAll<HTMLElement>("[data-code-panel]"),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (tabs.length === 0 || panels.length === 0) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const activateTab = (nextIndex: number, focus = false) => {
|
|
36
|
+
const clamped = Math.max(0, Math.min(nextIndex, tabs.length - 1));
|
|
37
|
+
|
|
38
|
+
tabs.forEach((tab, index) => {
|
|
39
|
+
const active = index === clamped;
|
|
40
|
+
tab.classList.toggle("docs-code-tab-active", active);
|
|
41
|
+
tab.setAttribute("aria-selected", active ? "true" : "false");
|
|
42
|
+
tab.tabIndex = active ? 0 : -1;
|
|
43
|
+
if (active && focus) {
|
|
44
|
+
tab.focus();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
panels.forEach((panel, index) => {
|
|
49
|
+
panel.hidden = index !== clamped;
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
tabs.forEach((tab, index) => {
|
|
54
|
+
const onClick = () => activateTab(index);
|
|
55
|
+
tab.addEventListener("click", onClick);
|
|
56
|
+
cleanups.push(() => tab.removeEventListener("click", onClick));
|
|
57
|
+
|
|
58
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
59
|
+
if (event.key === "ArrowRight") {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
activateTab((index + 1) % tabs.length, true);
|
|
62
|
+
} else if (event.key === "ArrowLeft") {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
activateTab((index - 1 + tabs.length) % tabs.length, true);
|
|
65
|
+
} else if (event.key === "Home") {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
activateTab(0, true);
|
|
68
|
+
} else if (event.key === "End") {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
activateTab(tabs.length - 1, true);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
tab.addEventListener("keydown", onKeyDown);
|
|
75
|
+
cleanups.push(() => tab.removeEventListener("keydown", onKeyDown));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const copyButtons = Array.from(
|
|
79
|
+
group.querySelectorAll<HTMLButtonElement>("[data-code-copy]"),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
copyButtons.forEach((button) => {
|
|
83
|
+
const onClick = async () => {
|
|
84
|
+
const panel = button.closest<HTMLElement>("[data-code-panel]");
|
|
85
|
+
if (!panel) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const code = panel.querySelector<HTMLElement>("pre code");
|
|
90
|
+
const text = code?.textContent?.replace(/\n$/, "") ?? "";
|
|
91
|
+
if (!text) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await navigator.clipboard.writeText(text);
|
|
97
|
+
} catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const copiedLabel = button.dataset.codeCopiedLabel ?? "Copied";
|
|
102
|
+
const copyLabel = button.dataset.codeCopyLabel ?? "Copy";
|
|
103
|
+
button.textContent = copiedLabel;
|
|
104
|
+
|
|
105
|
+
const timeoutId = window.setTimeout(() => {
|
|
106
|
+
button.textContent = copyLabel;
|
|
107
|
+
}, 1200);
|
|
108
|
+
|
|
109
|
+
cleanups.push(() => window.clearTimeout(timeoutId));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
button.addEventListener("click", onClick);
|
|
113
|
+
cleanups.push(() => button.removeEventListener("click", onClick));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const selectedTab = tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true");
|
|
117
|
+
activateTab(selectedTab >= 0 ? selectedTab : parseIndex(tabs[0].dataset.codeIndex ?? "0"));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
for (const cleanup of cleanups) {
|
|
122
|
+
cleanup();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { DocsNavNode } from "../framework/types";
|
|
2
|
+
import { MobileDocsMenu } from "./mobile-docs-menu";
|
|
3
|
+
import { CodeGroupEnhancer } from "./code-group-enhancer";
|
|
4
|
+
|
|
5
|
+
type DocsShellProps = {
|
|
6
|
+
pageHtml: string;
|
|
7
|
+
navigation: DocsNavNode[];
|
|
8
|
+
activeSlug: string;
|
|
9
|
+
locales: string[];
|
|
10
|
+
defaultLocale: string;
|
|
11
|
+
basePath: string;
|
|
12
|
+
tocItems: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
depth: number;
|
|
16
|
+
}>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function NavTree({ nodes, activeSlug }: { nodes: DocsNavNode[]; activeSlug: string }) {
|
|
20
|
+
return (
|
|
21
|
+
<ul className="docs-category-list">
|
|
22
|
+
{nodes.map((node) => {
|
|
23
|
+
const isCategory = node.path === null && node.children.length > 0;
|
|
24
|
+
|
|
25
|
+
if (isCategory) {
|
|
26
|
+
return (
|
|
27
|
+
<li key={node.id} className="docs-category">
|
|
28
|
+
<details open className="docs-category-details">
|
|
29
|
+
<summary className="docs-category-summary">
|
|
30
|
+
<span className="docs-category-title">{node.title}</span>
|
|
31
|
+
<span className="docs-category-chevron" aria-hidden="true">
|
|
32
|
+
▾
|
|
33
|
+
</span>
|
|
34
|
+
</summary>
|
|
35
|
+
<ul className="docs-nav-tree">
|
|
36
|
+
{node.children.map((child) => {
|
|
37
|
+
const isChildActive = child.slug === activeSlug;
|
|
38
|
+
return (
|
|
39
|
+
<li key={child.id}>
|
|
40
|
+
{child.path ? (
|
|
41
|
+
<a
|
|
42
|
+
href={child.path}
|
|
43
|
+
className={
|
|
44
|
+
isChildActive ? "docs-nav-link docs-nav-link-active" : "docs-nav-link"
|
|
45
|
+
}
|
|
46
|
+
aria-current={isChildActive ? "page" : undefined}
|
|
47
|
+
>
|
|
48
|
+
{child.title}
|
|
49
|
+
</a>
|
|
50
|
+
) : null}
|
|
51
|
+
</li>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</ul>
|
|
55
|
+
</details>
|
|
56
|
+
</li>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isActive = node.slug === activeSlug;
|
|
61
|
+
return (
|
|
62
|
+
<li key={node.id}>
|
|
63
|
+
{node.path ? (
|
|
64
|
+
<a
|
|
65
|
+
href={node.path}
|
|
66
|
+
className={isActive ? "docs-nav-link docs-nav-link-active" : "docs-nav-link"}
|
|
67
|
+
aria-current={isActive ? "page" : undefined}
|
|
68
|
+
>
|
|
69
|
+
{node.title}
|
|
70
|
+
</a>
|
|
71
|
+
) : (
|
|
72
|
+
<span className="docs-nav-group-label">{node.title}</span>
|
|
73
|
+
)}
|
|
74
|
+
</li>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</ul>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function DocsShell({
|
|
82
|
+
pageHtml,
|
|
83
|
+
navigation,
|
|
84
|
+
activeSlug,
|
|
85
|
+
locales,
|
|
86
|
+
defaultLocale,
|
|
87
|
+
basePath,
|
|
88
|
+
tocItems,
|
|
89
|
+
}: DocsShellProps) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="docs-grid">
|
|
92
|
+
<aside className="docs-sidebar">
|
|
93
|
+
<NavTree nodes={navigation} activeSlug={activeSlug} />
|
|
94
|
+
</aside>
|
|
95
|
+
|
|
96
|
+
<main className="docs-main">
|
|
97
|
+
<CodeGroupEnhancer />
|
|
98
|
+
|
|
99
|
+
<header className="docs-main-header">
|
|
100
|
+
<MobileDocsMenu
|
|
101
|
+
navigation={navigation}
|
|
102
|
+
activeSlug={activeSlug}
|
|
103
|
+
tocItems={tocItems}
|
|
104
|
+
locales={locales}
|
|
105
|
+
defaultLocale={defaultLocale}
|
|
106
|
+
basePath={basePath}
|
|
107
|
+
/>
|
|
108
|
+
</header>
|
|
109
|
+
|
|
110
|
+
{tocItems.length > 1 ? (
|
|
111
|
+
<nav className="docs-inline-toc">
|
|
112
|
+
<ul>
|
|
113
|
+
{tocItems.map((item) => (
|
|
114
|
+
<li key={item.id} className={item.depth > 2 ? "toc-nested" : undefined}>
|
|
115
|
+
<a href={`#${item.id}`}>{item.label}</a>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
</nav>
|
|
120
|
+
) : null}
|
|
121
|
+
|
|
122
|
+
<article
|
|
123
|
+
id="overview"
|
|
124
|
+
className="docs-prose"
|
|
125
|
+
dangerouslySetInnerHTML={{ __html: pageHtml }}
|
|
126
|
+
/>
|
|
127
|
+
</main>
|
|
128
|
+
|
|
129
|
+
<aside className="docs-toc">
|
|
130
|
+
<ul>
|
|
131
|
+
{tocItems.map((item) => (
|
|
132
|
+
<li key={item.id} className={item.depth > 2 ? "toc-nested" : undefined}>
|
|
133
|
+
<a href={`#${item.id}`}>{item.label}</a>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</aside>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type HeaderDropdownOption = {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type HeaderDropdownProps = {
|
|
11
|
+
ariaLabel: string;
|
|
12
|
+
value: string;
|
|
13
|
+
options: HeaderDropdownOption[];
|
|
14
|
+
leadingIcon: ReactNode;
|
|
15
|
+
onChange: (value: string) => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function ChevronDownIcon() {
|
|
20
|
+
return (
|
|
21
|
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
22
|
+
<path
|
|
23
|
+
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.12l3.71-3.9a.75.75 0 0 1 1.08 1.04l-4.25 4.47a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06Z"
|
|
24
|
+
fill="currentColor"
|
|
25
|
+
/>
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function HeaderDropdown({
|
|
31
|
+
ariaLabel,
|
|
32
|
+
value,
|
|
33
|
+
options,
|
|
34
|
+
leadingIcon,
|
|
35
|
+
onChange,
|
|
36
|
+
className,
|
|
37
|
+
}: HeaderDropdownProps) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
40
|
+
|
|
41
|
+
const selectedLabel = useMemo(
|
|
42
|
+
() => options.find((option) => option.value === value)?.label ?? value,
|
|
43
|
+
[options, value],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const listboxId = useMemo(
|
|
47
|
+
() => `header-dropdown-${ariaLabel.toLowerCase().replace(/\s+/g, "-")}`,
|
|
48
|
+
[ariaLabel],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
53
|
+
if (!rootRef.current?.contains(event.target as Node)) {
|
|
54
|
+
setOpen(false);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
59
|
+
if (event.key === "Escape") {
|
|
60
|
+
setOpen(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
65
|
+
document.addEventListener("keydown", onKeyDown);
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
69
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
70
|
+
};
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={
|
|
76
|
+
className
|
|
77
|
+
? `select-wrap header-dropdown ${className}`
|
|
78
|
+
: "select-wrap header-dropdown"
|
|
79
|
+
}
|
|
80
|
+
ref={rootRef}
|
|
81
|
+
>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="header-select-button"
|
|
85
|
+
aria-label={ariaLabel}
|
|
86
|
+
aria-haspopup="listbox"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
aria-controls={listboxId}
|
|
89
|
+
onClick={() => setOpen((previous) => !previous)}
|
|
90
|
+
>
|
|
91
|
+
<span className="select-leading-icon" aria-hidden="true">
|
|
92
|
+
{leadingIcon}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="header-select-value">{selectedLabel}</span>
|
|
95
|
+
<span
|
|
96
|
+
className={
|
|
97
|
+
open ? "select-trailing-icon select-trailing-icon-open" : "select-trailing-icon"
|
|
98
|
+
}
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
>
|
|
101
|
+
<ChevronDownIcon />
|
|
102
|
+
</span>
|
|
103
|
+
</button>
|
|
104
|
+
|
|
105
|
+
{open ? (
|
|
106
|
+
<div className="header-dropdown-panel">
|
|
107
|
+
<ul
|
|
108
|
+
id={listboxId}
|
|
109
|
+
role="listbox"
|
|
110
|
+
aria-label={ariaLabel}
|
|
111
|
+
className="header-dropdown-list"
|
|
112
|
+
>
|
|
113
|
+
{options.map((option) => (
|
|
114
|
+
<li key={option.value} role="presentation">
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
role="option"
|
|
118
|
+
aria-selected={option.value === value}
|
|
119
|
+
className={
|
|
120
|
+
option.value === value
|
|
121
|
+
? "header-dropdown-item header-dropdown-item-active"
|
|
122
|
+
: "header-dropdown-item"
|
|
123
|
+
}
|
|
124
|
+
onClick={() => {
|
|
125
|
+
onChange(option.value);
|
|
126
|
+
setOpen(false);
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{option.label}
|
|
130
|
+
</button>
|
|
131
|
+
</li>
|
|
132
|
+
))}
|
|
133
|
+
</ul>
|
|
134
|
+
</div>
|
|
135
|
+
) : null}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
|
|
3
|
+
type IconProps = SVGProps<SVGSVGElement>;
|
|
4
|
+
|
|
5
|
+
export function MoonIcon(props: IconProps) {
|
|
6
|
+
return (
|
|
7
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
8
|
+
<path
|
|
9
|
+
fill="currentColor"
|
|
10
|
+
d="M12.058 20q-3.333 0-5.667-2.334T4.058 12q0-2.47 1.413-4.535q1.414-2.067 4.01-2.973q.306-.107.536-.056t.381.199t.192.38q.04.233-.063.489q-.194.477-.282.971T10.158 7.5q0 2.673 1.863 4.537q1.864 1.863 4.537 1.863q.698 0 1.277-.148q.58-.148.988-.24q.218-.04.399.01t.292.176q.115.125.156.308t-.047.417q-.715 2.45-2.803 4.013T12.058 20"
|
|
11
|
+
/>
|
|
12
|
+
</svg>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SunIcon(props: IconProps) {
|
|
17
|
+
return (
|
|
18
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
19
|
+
<path
|
|
20
|
+
fill="currentColor"
|
|
21
|
+
d="M11.643 5.28q-.143-.145-.143-.357V2.539q0-.213.144-.357t.357-.144t.356.144t.143.356v2.385q0 .213-.144.356t-.357.144t-.356-.144m5 2.079q-.141-.14-.131-.342q.01-.2.15-.366l1.65-1.694q.15-.166.359-.166t.378.17q.155.156.155.35t-.16.354L17.35 7.358q-.16.16-.354.16t-.354-.16m2.435 5.142q-.213 0-.356-.144t-.144-.357t.144-.356t.356-.143h2.385q.212 0 .356.144t.143.357t-.143.356t-.357.143zm-7.434 9.318q-.143-.144-.143-.356v-2.366q0-.212.144-.356t.357-.144t.356.144t.143.356v2.366q0 .212-.144.356t-.357.144t-.356-.144M6.67 7.358l-1.713-1.67q-.165-.149-.165-.36t.165-.372q.16-.14.367-.14t.34.14l1.714 1.713q.14.134.14.341t-.136.348q-.15.14-.348.14t-.364-.14m11.662 11.686l-1.67-1.694q-.14-.165-.15-.366t.128-.342t.34-.14t.371.16l1.694 1.669q.146.14.143.344t-.149.37q-.16.164-.366.164t-.341-.165M2.539 12.5q-.213 0-.357-.144t-.143-.357t.143-.356t.357-.143h2.384q.213 0 .356.144t.144.357t-.144.356t-.356.143zm2.436 6.544q-.14-.16-.15-.363t.13-.344l1.676-1.675q.16-.16.35-.16t.358.155q.165.17.165.371t-.165.366l-1.65 1.65q-.166.166-.373.166t-.341-.166m3.487-3.501Q7 14.086 7 12.005T8.457 8.46T11.995 7t3.544 1.457T17 11.995t-1.457 3.544T12.005 17T8.46 15.543"
|
|
22
|
+
/>
|
|
23
|
+
</svg>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function LanguageIcon(props: IconProps) {
|
|
28
|
+
return (
|
|
29
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
30
|
+
<path
|
|
31
|
+
fill="currentColor"
|
|
32
|
+
d="M8.125 21.213q-1.825-.788-3.187-2.15t-2.15-3.188T2 11.988t.788-3.875t2.15-3.175t3.187-2.15T12.013 2t3.875.788t3.175 2.15t2.15 3.175t.787 3.875t-.787 3.887t-2.15 3.188t-3.175 2.15t-3.875.787t-3.888-.787M12 19.95q.65-.9 1.125-1.875T13.9 16h-3.8q.3 1.1.775 2.075T12 19.95m-2.6-.4q-.45-.825-.787-1.713T8.05 16H5.1q.725 1.25 1.813 2.175T9.4 19.55m5.2 0q1.4-.45 2.488-1.375T18.9 16h-2.95q-.225.95-.562 1.838T14.6 19.55M4.25 14h3.4q-.075-.5-.112-.987T7.5 12t.038-1.012T7.65 10h-3.4q-.125.5-.187.988T4 12t.063 1.013t.187.987m5.4 0h4.7q.075-.5.113-.987T14.5 12t-.038-1.012T14.35 10h-4.7q-.075.5-.112.988T9.5 12t.038 1.013t.112.987m6.7 0h3.4q.125-.5.188-.987T20 12t-.062-1.012T19.75 10h-3.4q.075.5.113.988T16.5 12t-.038 1.013t-.112.987m-.4-6h2.95q-.725-1.25-1.812-2.175T14.6 4.45q.45.825.788 1.713T15.95 8M10.1 8h3.8q-.3-1.1-.775-2.075T12 4.05q-.65.9-1.125 1.875T10.1 8m-5 0h2.95q.225-.95.563-1.838T9.4 4.45Q8 4.9 6.912 5.825T5.1 8"
|
|
33
|
+
/>
|
|
34
|
+
</svg>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function MenuIcon(props: IconProps) {
|
|
39
|
+
return (
|
|
40
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
41
|
+
<path
|
|
42
|
+
fill="currentColor"
|
|
43
|
+
d="M4.75 6.5a.75.75 0 0 1 .75-.75h13a.75.75 0 1 1 0 1.5h-13a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h13a.75.75 0 1 1 0 1.5h-13a.75.75 0 0 1-.75-.75m.75 4.75a.75.75 0 1 0 0 1.5h13a.75.75 0 0 0 0-1.5z"
|
|
44
|
+
/>
|
|
45
|
+
</svg>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function CloseIcon(props: IconProps) {
|
|
50
|
+
return (
|
|
51
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
52
|
+
<path
|
|
53
|
+
fill="currentColor"
|
|
54
|
+
d="M6.47 6.47a.75.75 0 0 1 1.06 0L12 10.94l4.47-4.47a.75.75 0 0 1 1.06 1.06L13.06 12l4.47 4.47a.75.75 0 1 1-1.06 1.06L12 13.06l-4.47 4.47a.75.75 0 0 1-1.06-1.06L10.94 12L6.47 7.53a.75.75 0 0 1 0-1.06"
|
|
55
|
+
/>
|
|
56
|
+
</svg>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { HeaderDropdown } from "./header-dropdown";
|
|
6
|
+
import { LanguageIcon } from "./icons";
|
|
7
|
+
|
|
8
|
+
type LocaleSwitcherProps = {
|
|
9
|
+
locales: string[];
|
|
10
|
+
defaultLocale: string;
|
|
11
|
+
basePath: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function isSupportedLocale(value: string, locales: string[]): boolean {
|
|
16
|
+
return locales.includes(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizePath(pathname: string): string {
|
|
20
|
+
if (!pathname.startsWith("/")) {
|
|
21
|
+
return `/${pathname}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return pathname;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toLocalePath(
|
|
28
|
+
pathname: string,
|
|
29
|
+
targetLocale: string,
|
|
30
|
+
locales: string[],
|
|
31
|
+
basePath: string,
|
|
32
|
+
): string {
|
|
33
|
+
const normalized = normalizePath(pathname);
|
|
34
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
35
|
+
|
|
36
|
+
if (segments.length > 0 && isSupportedLocale(segments[0], locales)) {
|
|
37
|
+
segments[0] = targetLocale;
|
|
38
|
+
return `/${segments.join("/")}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (normalized === "/") {
|
|
42
|
+
return `/${targetLocale}${basePath}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `/${targetLocale}${normalized}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function localeLabel(locale: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const displayNames = new Intl.DisplayNames([locale], { type: "language" });
|
|
51
|
+
const languageCode = locale.split("-")[0];
|
|
52
|
+
return displayNames.of(languageCode) ?? locale;
|
|
53
|
+
} catch {
|
|
54
|
+
return locale;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function LocaleSwitcher({
|
|
59
|
+
locales,
|
|
60
|
+
defaultLocale,
|
|
61
|
+
basePath,
|
|
62
|
+
className,
|
|
63
|
+
}: LocaleSwitcherProps) {
|
|
64
|
+
const pathname = usePathname();
|
|
65
|
+
|
|
66
|
+
const currentLocale = useMemo(() => {
|
|
67
|
+
const first = pathname.split("/").filter(Boolean)[0];
|
|
68
|
+
if (isSupportedLocale(first, locales)) {
|
|
69
|
+
return first;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return defaultLocale;
|
|
73
|
+
}, [defaultLocale, locales, pathname]);
|
|
74
|
+
|
|
75
|
+
const options = useMemo(
|
|
76
|
+
() =>
|
|
77
|
+
locales.map((locale) => ({
|
|
78
|
+
value: locale,
|
|
79
|
+
label: localeLabel(locale),
|
|
80
|
+
})),
|
|
81
|
+
[locales],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<HeaderDropdown
|
|
86
|
+
ariaLabel="Language"
|
|
87
|
+
leadingIcon={<LanguageIcon />}
|
|
88
|
+
value={currentLocale}
|
|
89
|
+
options={options}
|
|
90
|
+
className={className}
|
|
91
|
+
onChange={(nextLocale) => {
|
|
92
|
+
const nextPath = toLocalePath(pathname, nextLocale, locales, basePath);
|
|
93
|
+
window.location.assign(nextPath);
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|