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:
Claude
2026-05-11 08:26:30 +02:00
parent 1f2141118d
commit d430680df5
13 changed files with 460 additions and 580 deletions

View File

@@ -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)) {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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} />;
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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";