feat: v0.3.0 — Phase 3/4 façades + AppLayout on AppShell
- Curated re-exports from @limitless/ui through /forms, /data, /feedback, /navigation, /utils sub-paths so apps stop importing from the lower layer. - /forms also re-exports the full @limitless/ui validation surface (hooks, format/security/address validators, types). - AppLayout is now a thin wrapper over @limitless/ui's <AppShell> — same ShellConfig DTO, no duplicated chrome code. - shell/types + shell/index re-export from @limitless/ui to keep one canonical type and one shared context. - auth middleware: loose NextRequestLike typing to avoid two-copies- of-next conflict with the consumer's next. - postbuild: rewrite ../images/ to ./images/ in copied CSS so refs resolve in dist/styles/. Widget family in /data is the intersection of limitless's two Widget files (Widget.d.ts vs Widget/index.d.ts collision in dist); upstream fix needed before exposing IconWidget/UserWidget/etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,16 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Loose local typing for NextRequest. Importing next's official type
|
||||
// here pulls in `next/server` from THIS package's node_modules, which
|
||||
// causes a "two copies of next" type conflict with the consumer's
|
||||
// next. The shape below is the surface area the middleware actually
|
||||
// uses; structural typing is enough at the call site.
|
||||
interface NextRequestLike {
|
||||
nextUrl: URL;
|
||||
cookies: {
|
||||
get(name: string): { value: string } | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthMiddlewareOptions {
|
||||
/**
|
||||
@@ -38,7 +50,7 @@ export function createAuthMiddleware(opts: AuthMiddlewareOptions = {}) {
|
||||
const publicRoutes = opts.publicRoutes ?? [];
|
||||
const signInPath = opts.signInPath ?? "/api/auth/signin/keycloak";
|
||||
|
||||
return function middleware(req: NextRequest) {
|
||||
return function middleware(req: NextRequestLike) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (isAlwaysAllowed(pathname)) {
|
||||
|
||||
@@ -1,2 +1,37 @@
|
||||
// @gsc/web-kit/data — Phase 1 stub. Real surface lands in later phases.
|
||||
export {};
|
||||
/**
|
||||
* @gsc/web-kit/data — data display primitives.
|
||||
*
|
||||
* Curated re-export from @limitless/ui. Use these for entity lists,
|
||||
* detail panels, and timeline/calendar views.
|
||||
*
|
||||
* The Widget family is intentionally narrow: only names that exist in
|
||||
* both Widget.d.ts and Widget/index.d.ts are re-exported, because the
|
||||
* installed limitless dist has a duplicate-file collision there. If
|
||||
* apps need IconWidget / UserWidget / etc., import from @limitless/ui
|
||||
* directly until upstream is fixed.
|
||||
*/
|
||||
|
||||
export {
|
||||
Table,
|
||||
DataTable,
|
||||
Pagination,
|
||||
TreeView,
|
||||
Timeline,
|
||||
Calendar,
|
||||
Gallery,
|
||||
Sortable,
|
||||
ListGroup,
|
||||
// Widget family (intersection of the two Widget files in dist)
|
||||
StatWidget,
|
||||
ProgressWidget,
|
||||
ChartWidget,
|
||||
ContentWidget,
|
||||
} from "@limitless/ui";
|
||||
|
||||
export type {
|
||||
DataTableProps,
|
||||
StatWidgetProps,
|
||||
ProgressWidgetProps,
|
||||
ChartWidgetProps,
|
||||
ContentWidgetProps,
|
||||
} from "@limitless/ui";
|
||||
|
||||
@@ -1,2 +1,25 @@
|
||||
// @gsc/web-kit/feedback — Phase 1 stub. Real surface lands in later phases.
|
||||
export {};
|
||||
/**
|
||||
* @gsc/web-kit/feedback — overlays, toasts, status indicators.
|
||||
*
|
||||
* Curated re-export from @limitless/ui.
|
||||
*/
|
||||
|
||||
export {
|
||||
Alert,
|
||||
Toast,
|
||||
Notification,
|
||||
Modal,
|
||||
Offcanvas,
|
||||
Popover,
|
||||
Tooltip,
|
||||
SweetAlert,
|
||||
Spinner,
|
||||
Progress,
|
||||
ProgressStacked,
|
||||
IdleTimeout,
|
||||
FAB,
|
||||
} from "@limitless/ui";
|
||||
|
||||
export type {
|
||||
ToastProps,
|
||||
} from "@limitless/ui";
|
||||
|
||||
@@ -1,2 +1,146 @@
|
||||
// @gsc/web-kit/forms — Phase 1 stub. Real surface lands in later phases.
|
||||
export {};
|
||||
/**
|
||||
* @gsc/web-kit/forms — form primitives + validation.
|
||||
*
|
||||
* Curated re-export from @limitless/ui. Apps import everything here
|
||||
* instead of reaching into the lower layer:
|
||||
*
|
||||
* import { Form, FormGroup, FormControl, Select, SelectSingle,
|
||||
* useValidation, required, email } from "@gsc/web-kit/forms";
|
||||
*/
|
||||
|
||||
// Form primitives
|
||||
export {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
FormCheck,
|
||||
Select,
|
||||
InputGroup,
|
||||
} from "@limitless/ui";
|
||||
export type {
|
||||
FormGroupProps,
|
||||
FormControlProps,
|
||||
FormCheckProps,
|
||||
SelectOption,
|
||||
SelectProps,
|
||||
InputGroupProps,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// Rich selects (react-select family — AdvancedSelect module)
|
||||
export {
|
||||
SelectSingle,
|
||||
MultiSelect,
|
||||
TagsSelect,
|
||||
AsyncSelect,
|
||||
} from "@limitless/ui";
|
||||
export type {
|
||||
SelectSingleProps,
|
||||
SelectMultiProps,
|
||||
CreatableSelectProps,
|
||||
AsyncSelectProps,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// Rich inputs
|
||||
export {
|
||||
DatePicker,
|
||||
ColorPicker,
|
||||
TagInput,
|
||||
FileUpload,
|
||||
Slider,
|
||||
Rating,
|
||||
DualListBox,
|
||||
ImageCropper,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// Multi-step
|
||||
export { Wizard, Stepper } from "@limitless/ui";
|
||||
|
||||
// Validation — hooks (re-exported via @limitless/ui root: `export * from './validation'`)
|
||||
export {
|
||||
useValidation,
|
||||
useFieldValidation,
|
||||
useAddressAutocomplete,
|
||||
loadGoogleMapsScript,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// Validation — format validators
|
||||
export {
|
||||
required,
|
||||
minLength,
|
||||
maxLength,
|
||||
pattern,
|
||||
matches,
|
||||
email,
|
||||
url,
|
||||
tel,
|
||||
number,
|
||||
range,
|
||||
date,
|
||||
time,
|
||||
datetimeLocal,
|
||||
month,
|
||||
week,
|
||||
color,
|
||||
search,
|
||||
password,
|
||||
getPasswordStrength,
|
||||
file,
|
||||
checkbox,
|
||||
radio,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// Validation — security
|
||||
export {
|
||||
noInjection,
|
||||
noSqlInjection,
|
||||
noScriptInjection,
|
||||
noHtmlInjection,
|
||||
noPromptInjection,
|
||||
detectInjectionType,
|
||||
SQL_INJECTION_PATTERNS,
|
||||
SCRIPT_INJECTION_PATTERNS,
|
||||
HTML_INJECTION_PATTERNS,
|
||||
PROMPT_INJECTION_PATTERNS,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// Validation — address
|
||||
export {
|
||||
postalCode,
|
||||
europeanPostalCode,
|
||||
city,
|
||||
streetAddress,
|
||||
houseNumber,
|
||||
europeanCountry,
|
||||
europeanAddress,
|
||||
getCountryName,
|
||||
getPostalCodeHint,
|
||||
POSTAL_CODE_PATTERNS,
|
||||
EU_COUNTRIES,
|
||||
EEA_COUNTRIES,
|
||||
EUROPEAN_COUNTRIES,
|
||||
} from "@limitless/ui";
|
||||
|
||||
// Validation — types
|
||||
export type {
|
||||
ValidationStatus,
|
||||
ValidationResult,
|
||||
FieldState,
|
||||
FormState,
|
||||
ValidatorFn,
|
||||
FieldSchema,
|
||||
FormSchema,
|
||||
EuropeanAddress,
|
||||
AddressSuggestion,
|
||||
SecurityValidationOptions,
|
||||
PasswordStrength,
|
||||
PasswordOptions,
|
||||
PhoneOptions,
|
||||
NumberOptions,
|
||||
DateOptions,
|
||||
TimeOptions,
|
||||
FileOptions,
|
||||
UseValidationOptions,
|
||||
UseFieldValidationOptions,
|
||||
UseAddressAutocompleteOptions,
|
||||
ServerValidationSchema,
|
||||
ServerValidationResult,
|
||||
} from "@limitless/ui";
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { AppShell } from "@limitless/ui";
|
||||
|
||||
import { ShellProvider } from "../shell/index";
|
||||
import type {
|
||||
ShellConfig,
|
||||
ShellMenuItem,
|
||||
} from "../shell/types";
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "gsc-web-kit-sidebar-collapsed";
|
||||
import type { ShellConfig } from "../shell/types";
|
||||
|
||||
export interface AppLayoutProps {
|
||||
/** Pre-resolved chrome config. Fetch with `fetchShellConfig()` server-side. */
|
||||
@@ -26,378 +21,11 @@ export interface AppLayoutProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The chrome. Renders a Bootstrap-Layout-3 navbar / sidebar / footer
|
||||
* shell from a `ShellConfig`. Apps wrap their `[locale]/layout.tsx`
|
||||
* children in this and get a consistent app look for free.
|
||||
* GSC app chrome. Thin wrapper around `<AppShell>` from `@limitless/ui` —
|
||||
* the two share the same `ShellConfig` DTO. Kept as a separate export so
|
||||
* the kit owns the consumer-facing surface and can add GSC-only props
|
||||
* (telemetry, feature flags, etc.) without forking limitless.
|
||||
*/
|
||||
export function AppLayout({
|
||||
config,
|
||||
currentPath,
|
||||
translate,
|
||||
onSignOut,
|
||||
navbarExtras,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const t = translate ?? ((k: string) => k);
|
||||
const path =
|
||||
currentPath ??
|
||||
(typeof window !== "undefined" ? window.location.pathname : "/");
|
||||
|
||||
return (
|
||||
<ShellProvider value={config}>
|
||||
<AdminNavbar
|
||||
config={config}
|
||||
t={t}
|
||||
onSignOut={onSignOut}
|
||||
navbarExtras={navbarExtras}
|
||||
/>
|
||||
<div
|
||||
className="page-content d-flex align-items-start p-3 gap-3"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<AdminSidebar config={config} t={t} pathname={path} />
|
||||
<main className="flex-grow-1" style={{ minWidth: 0 }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<AdminFooter config={config} t={t} />
|
||||
</ShellProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Navbar ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function AdminNavbar({
|
||||
config,
|
||||
t,
|
||||
onSignOut,
|
||||
navbarExtras,
|
||||
}: {
|
||||
config: ShellConfig;
|
||||
t: (k: string) => string;
|
||||
onSignOut?: () => void;
|
||||
navbarExtras?: React.ReactNode;
|
||||
}) {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
const initials =
|
||||
config.user.displayName
|
||||
?.split(" ")
|
||||
.map((n) => n[0])
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || "?";
|
||||
|
||||
const userMenuItems = config.menus["user-menu"] ?? [];
|
||||
|
||||
return (
|
||||
<div className="navbar navbar-dark navbar-expand-lg navbar-static">
|
||||
<div className="container-fluid">
|
||||
<div className="navbar-brand wmin-200">
|
||||
<a href={config.app.baseUrl} className="d-inline-block">
|
||||
<img
|
||||
src={config.branding.logoUrl}
|
||||
className="h-36px"
|
||||
alt={config.branding.productName}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{navbarExtras}
|
||||
|
||||
<ul className="nav flex-row justify-content-end order-1 order-lg-2 ms-auto">
|
||||
<li
|
||||
className="nav-item nav-item-dropdown-lg dropdown ms-lg-2"
|
||||
onMouseLeave={() => setShowUserMenu(false)}
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
className="navbar-nav-link align-items-center rounded-pill p-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowUserMenu((open) => !open);
|
||||
}}
|
||||
>
|
||||
<div className="status-indicator-container">
|
||||
<span
|
||||
className="w-32px h-32px rounded-pill bg-primary bg-opacity-20 text-primary d-inline-flex align-items-center justify-content-center fw-semibold"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span className="status-indicator bg-success" />
|
||||
</div>
|
||||
<span className="d-none d-lg-inline-block mx-lg-2">
|
||||
{config.user.displayName || config.user.email || ""}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div
|
||||
className={
|
||||
showUserMenu
|
||||
? "dropdown-menu dropdown-menu-end show"
|
||||
: "dropdown-menu dropdown-menu-end"
|
||||
}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "0px 0px auto auto",
|
||||
margin: "0px",
|
||||
transform: "translate3d(0px, 44px, 0px)",
|
||||
}}
|
||||
>
|
||||
{userMenuItems.map((m) => {
|
||||
const isLogout = m.key === "logout";
|
||||
return (
|
||||
<a
|
||||
key={m.id}
|
||||
href={m.href}
|
||||
target={m.isExternal ? "_blank" : undefined}
|
||||
rel={m.isExternal ? "noopener noreferrer" : undefined}
|
||||
className="dropdown-item"
|
||||
onClick={
|
||||
isLogout && onSignOut
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
onSignOut();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{m.icon ? <i className={`${m.icon} me-2`} /> : null}
|
||||
{t(m.translationKey)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function AdminSidebar({
|
||||
config,
|
||||
t,
|
||||
pathname,
|
||||
}: {
|
||||
config: ShellConfig;
|
||||
t: (k: string) => string;
|
||||
pathname: string;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
|
||||
if (saved === "true") setCollapsed(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed));
|
||||
}, [collapsed]);
|
||||
|
||||
const sidebarClasses = [
|
||||
"sidebar",
|
||||
"sidebar-light",
|
||||
"sidebar-main",
|
||||
"sidebar-expand-lg",
|
||||
"align-self-start",
|
||||
collapsed ? "sidebar-main-resized" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const items = config.menus.sidebar ?? [];
|
||||
const strippedPath = stripLocale(pathname);
|
||||
|
||||
return (
|
||||
<div className={sidebarClasses}>
|
||||
<div className="sidebar-content">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-body d-flex justify-content-center">
|
||||
{!collapsed && (
|
||||
<h5 className="sidebar-resize-hide flex-grow-1 my-auto">
|
||||
{config.branding.productName}
|
||||
</h5>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-light btn-icon btn-sm rounded-pill border-transparent sidebar-control sidebar-main-resize d-none d-lg-inline-flex"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<i className="ph-arrows-left-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<ul className="nav nav-sidebar" data-nav-type="accordion">
|
||||
{items.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
pathname={strippedPath}
|
||||
collapsed={collapsed}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNavItem({
|
||||
item,
|
||||
pathname,
|
||||
collapsed,
|
||||
t,
|
||||
}: {
|
||||
item: ShellMenuItem;
|
||||
pathname: string;
|
||||
collapsed: boolean;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const hasChildren = !!item.children?.length;
|
||||
const active = isActiveHref(item.href, pathname);
|
||||
const childActive = !!item.children?.some((c) =>
|
||||
isActiveHref(c.href, pathname),
|
||||
);
|
||||
const [open, setOpen] = useState(childActive);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const icon = item.icon ? <i className={item.icon} /> : null;
|
||||
|
||||
if (hasChildren) {
|
||||
const showFlyout = collapsed && hovered;
|
||||
const showInline = !collapsed && open;
|
||||
const navItemClasses = [
|
||||
"nav-item",
|
||||
"nav-item-submenu",
|
||||
showInline ? "nav-item-open" : "",
|
||||
showFlyout ? "nav-group-sub-visible" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<li
|
||||
className={navItemClasses}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
className={`nav-link ${active || childActive ? "active" : ""}`}
|
||||
title={collapsed ? t(item.translationKey) : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && <span>{t(item.translationKey)}</span>}
|
||||
</a>
|
||||
<ul
|
||||
className={
|
||||
collapsed
|
||||
? "nav-group-sub nav-group-sub-flyout collapse"
|
||||
: `nav-group-sub collapse ${open ? "show" : ""}`
|
||||
}
|
||||
data-submenu-title={t(item.translationKey)}
|
||||
>
|
||||
{item.children!.map((c) => (
|
||||
<li className="nav-item" key={c.id}>
|
||||
<a
|
||||
href={c.href}
|
||||
target={c.isExternal ? "_blank" : undefined}
|
||||
rel={c.isExternal ? "noopener noreferrer" : undefined}
|
||||
className={`nav-link ${isActiveHref(c.href, pathname) ? "active" : ""}`}
|
||||
>
|
||||
{c.icon && <i className={c.icon} />}
|
||||
{t(c.translationKey)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="nav-item">
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.isExternal ? "_blank" : undefined}
|
||||
rel={item.isExternal ? "noopener noreferrer" : undefined}
|
||||
className={`nav-link ${active ? "active" : ""}`}
|
||||
title={collapsed ? t(item.translationKey) : undefined}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && <span>{t(item.translationKey)}</span>}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Footer ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function AdminFooter({
|
||||
config,
|
||||
t,
|
||||
}: {
|
||||
config: ShellConfig;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const items = config.menus.footer ?? [];
|
||||
return (
|
||||
<div className="navbar navbar-sm navbar-footer border-top">
|
||||
<div className="container-fluid">
|
||||
<span>
|
||||
{config.branding.footerHtml ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }}
|
||||
/>
|
||||
) : (
|
||||
<>© {new Date().getFullYear()} GoSec Cloud</>
|
||||
)}
|
||||
</span>
|
||||
{items.length > 0 && (
|
||||
<ul className="navbar-nav flex-row gap-3 ms-auto">
|
||||
{items.map((m) => (
|
||||
<li className="nav-item" key={m.id}>
|
||||
<a
|
||||
href={m.href}
|
||||
target={m.isExternal ? "_blank" : undefined}
|
||||
rel={m.isExternal ? "noopener noreferrer" : undefined}
|
||||
className="navbar-nav-link"
|
||||
>
|
||||
{m.icon && <i className={`${m.icon} me-1`} />}
|
||||
{t(m.translationKey)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function isActiveHref(href: string, currentPath: string): boolean {
|
||||
if (!href || href === "#") return false;
|
||||
if (href === "/") return currentPath === "/";
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function stripLocale(p: string): string {
|
||||
return p.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
|
||||
export function AppLayout(props: AppLayoutProps) {
|
||||
return <AppShell {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,26 @@
|
||||
// @gsc/web-kit/navigation — Phase 1 stub. Real surface lands in later phases.
|
||||
export {};
|
||||
/**
|
||||
* @gsc/web-kit/navigation — nav, breadcrumbs, page chrome, content
|
||||
* organisers.
|
||||
*
|
||||
* Curated re-export from @limitless/ui.
|
||||
*/
|
||||
|
||||
export {
|
||||
Breadcrumbs,
|
||||
Nav,
|
||||
Tabs,
|
||||
Pills,
|
||||
Dropdown,
|
||||
ContextMenu,
|
||||
Scrollspy,
|
||||
PageHeader,
|
||||
Accordion,
|
||||
Collapse,
|
||||
Carousel,
|
||||
Embed,
|
||||
SyntaxHighlighter,
|
||||
Card,
|
||||
Badge,
|
||||
Button,
|
||||
Media,
|
||||
} from "@limitless/ui";
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
// Client surface for @gsc/web-kit/shell.
|
||||
//
|
||||
// The context provider lives inside @limitless/ui's <AppShell>; we just
|
||||
// re-export the `useShell()` hook so pages rendered under <AppLayout>
|
||||
// (which delegates to AppShell) can read the config.
|
||||
|
||||
import type { ShellConfig } from "./types";
|
||||
export { useShell } from "@limitless/ui";
|
||||
|
||||
export type {
|
||||
ShellApp,
|
||||
@@ -12,17 +16,3 @@ export type {
|
||||
ShellMenuZone,
|
||||
ShellUser,
|
||||
} from "./types";
|
||||
|
||||
const ShellContext = createContext<ShellConfig | null>(null);
|
||||
|
||||
/** Provider used by `<AppLayout>`; rarely needed directly. */
|
||||
export const ShellProvider = ShellContext.Provider;
|
||||
|
||||
/** Read the current ShellConfig anywhere inside `<AppLayout>`. */
|
||||
export function useShell(): ShellConfig {
|
||||
const cfg = useContext(ShellContext);
|
||||
if (!cfg) {
|
||||
throw new Error("useShell must be used inside <AppLayout>");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,11 @@
|
||||
/**
|
||||
* Shape of what gsc-shell-api returns. Mirror of the Go service's DTO.
|
||||
* If you change one, change both — there's a runtime contract in
|
||||
* between, not a code-generator.
|
||||
*/
|
||||
|
||||
export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
|
||||
|
||||
export interface ShellMenuItem {
|
||||
id: string;
|
||||
key: string;
|
||||
translationKey: string;
|
||||
href: string;
|
||||
icon?: string;
|
||||
isExternal?: boolean;
|
||||
children?: ShellMenuItem[];
|
||||
}
|
||||
|
||||
export interface ShellApp {
|
||||
key: string;
|
||||
displayName: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface ShellBranding {
|
||||
logoUrl: string;
|
||||
productName: string;
|
||||
footerHtml?: string;
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export interface ShellUser {
|
||||
id: string;
|
||||
email?: string;
|
||||
displayName: string;
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
tenantId?: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface ShellConfig {
|
||||
version: number;
|
||||
app: ShellApp;
|
||||
branding: ShellBranding;
|
||||
user: ShellUser;
|
||||
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
|
||||
}
|
||||
// Re-export from @limitless/ui so the kit and the underlying AppShell
|
||||
// share one canonical type for ShellConfig. Diverging here would mean
|
||||
// the kit's <AppLayout> can't be fed by limitless and vice versa.
|
||||
export type {
|
||||
ShellApp,
|
||||
ShellBranding,
|
||||
ShellConfig,
|
||||
ShellMenuItem,
|
||||
ShellMenuZone,
|
||||
ShellUser,
|
||||
} from "@limitless/ui";
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
// @gsc/web-kit/utils — Phase 1 stub. Real surface lands in later phases.
|
||||
export {};
|
||||
/**
|
||||
* @gsc/web-kit/utils — shared hooks and helpers.
|
||||
*
|
||||
* Curated re-export from @limitless/ui.
|
||||
*/
|
||||
|
||||
export { useDisclosure } from "@limitless/ui";
|
||||
|
||||
Reference in New Issue
Block a user