feat(chrome)!: v0.4.0 — AdminShell + headers as /chrome sub-export

New `./chrome` entrypoint exporting `<AdminShell>` and the header
components (Search, SearchHistory, SearchOptions, Messages, BrowseApps,
HeaderCustomers, HeaderContacts, LogoutButton). Refactored from the
Chronos-style AdminShell that gscCRM was vendoring byte-for-byte —
header/footer/sidebar are now a single shared surface across apps.

Explicit props contract (no site-informations.json, no internal data
sources): `menus`, `apps`, `user`, `brand` are required; `features.*`
flags gate every section (search/browseApps/messages/notifications/
subbar*/pageHeader*/activityPanel/chat/footer); `slots.*` lets apps
inject content; `labels` overrides the next-intl "chrome" namespace.

Locale-aware navigation: chrome calls useLocale() and prepends
/{locale} to internal menu URLs, leaving externals (http(s)://…) and
the "#" sentinel alone. Breadcrumbs and the path-derived page title
strip the leading locale segment so they read "Contacts" not
"En › Contacts". Necessary for `localePrefix: 'always'` consumers like
gscCRM.

Phosphor 2.x icons: `normalizeIconClass` prepends the base `ph` class
(compound selectors `.ph.ph-house:before` require both). All hardcoded
`<i className="ph-…">` sites switched to `ph ph-…`.

`next-intl` and `next-auth` moved to peerDependencies (with devDep
copies for the kit's own typecheck/build). Consumers must symlink their
installed copies into the kit's node_modules at build time — otherwise
useTranslations()/useSession() bind to a separate React context and
next-intl throws Error(void 0) on render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-12 11:24:16 +02:00
parent 387e10b2fb
commit 440f815df7
18 changed files with 2146 additions and 14 deletions

1111
src/chrome/AdminShell.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
"use client";
import { signOut } from "next-auth/react";
/**
* Default flow (shared org Keycloak):
* 1. GET /api/auth/logout — host app returns { logoutUrl } pointing at
* Keycloak's end_session endpoint (id_token_hint included).
* 2. next-auth signOut() locally — fires events.signOut for backchannel
* revocation; redirect:false so we control the navigation.
* 3. Navigate to logoutUrl — kills the SSO cookie at Keycloak.
*
* Apps without /api/auth/logout (or that need a different flow) pass
* `onSignOut` to fully replace this behavior.
*/
type LogoutButtonProps = {
label: string;
onSignOut?: () => void | Promise<void>;
};
export function LogoutButton({ label, onSignOut }: LogoutButtonProps) {
const handleLogout = async () => {
if (onSignOut) {
await onSignOut();
return;
}
let logoutUrl = "/logged-out";
try {
const res = await fetch("/api/auth/logout");
const body = await res.json();
if (body?.logoutUrl) logoutUrl = body.logoutUrl;
} catch {
// fall through with local-only logout
}
await signOut({ redirect: false });
window.location.href = logoutUrl;
};
return (
<button type="button" onClick={handleLogout} className="dropdown-item">
<i className="ph-sign-out me-2"></i>
{label}
</button>
);
}

17
src/chrome/_ambient.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Ambient declarations for peer dependencies we don't install in the kit.
// `@gsc/chat` is provided by the consuming app — declared here so the kit
// can typecheck against the surface we use without owning a hard dep.
declare module "@gsc/chat" {
import type { FC } from "react";
export const ChatBubble: FC<{
isOpen: boolean;
onClick: () => void;
}>;
export const ChatOverlay: FC<{
isOpen: boolean;
onClose: () => void;
}>;
}

View File

@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import type { AppListItem, ChromeLabels } from "../types";
type BrowseAppsProps = {
show?: boolean;
apps: AppListItem[];
viewAllUrl?: string; // default "/apps"
labels: Pick<ChromeLabels, "browseApps" | "viewAll">;
};
export function BrowseApps({ show = true, apps, viewAllUrl = "/apps", labels }: BrowseAppsProps) {
const [open, setOpen] = useState(false);
const close = () => {
if (open) setOpen(false);
};
const enabled = apps.filter((a) => a.enabled).sort((a, b) => a.sortOrder - b.sortOrder);
return (
<>
{/* Mobile search icon (always rendered alongside browse-apps trigger) */}
<li className="nav-item d-lg-none">
<Link
href="#navbar_search"
className="navbar-nav-link navbar-nav-link-icon rounded-pill"
>
<i className="ph ph-magnifying-glass"></i>
</Link>
</li>
{show && (
<li
className="nav-item nav-item-dropdown-lg dropdown"
onMouseLeave={close}
>
<Link
href="#"
className="navbar-nav-link navbar-nav-link-icon rounded-pill"
onClick={(e) => {
e.preventDefault();
setOpen(!open);
}}
>
<i className="ph ph-squares-four"></i>
</Link>
<div
className={
open
? "dropdown-menu dropdown-menu-scrollable-sm wmin-lg-600 p-0 show"
: "dropdown-menu dropdown-menu-scrollable-sm wmin-lg-600 p-0"
}
>
<div className="d-flex align-items-center border-bottom p-3">
<h6 className="mb-0">{labels.browseApps}</h6>
<Link href={viewAllUrl} className="ms-auto">
{labels.viewAll}
<i className="ph ph-arrow-circle-right ms-1"></i>
</Link>
</div>
<div className="row row-cols-1 row-cols-sm-2 g-0">
{enabled.map((app, idx) => {
const isLast = idx === enabled.length - 1;
const isEvenColumn = idx % 2 === 0;
const itemClasses = [
"dropdown-item",
"text-wrap",
"h-100",
"align-items-start",
isEvenColumn ? "border-end-sm" : "",
isLast ? "" : "border-bottom",
"p-3",
]
.filter(Boolean)
.join(" ");
return (
<div className="col" key={app.key}>
<Link href={app.url} className={itemClasses}>
<div>
{app.iconUrl ? (
<img src={app.iconUrl} className="h-40px mb-2" alt="" />
) : app.iconClass ? (
<div
className={`d-flex align-items-center justify-content-center h-40px w-40px rounded mb-2 ${app.iconBg ?? "bg-secondary-lt"}`}
>
<i className={`${app.iconClass} fs-2`}></i>
</div>
) : null}
<div className="fw-semibold my-1">{app.name}</div>
{app.description && (
<div className="text-muted">{app.description}</div>
)}
</div>
</Link>
</div>
);
})}
</div>
</div>
</li>
)}
</>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
// Placeholder for parity with gscAdmin's contacts header zone. Renders nothing
// today; an app may pass slots.pageHeaderExtras to render its own contacts UI.
type HeaderContactsProps = {
show?: boolean;
};
export function HeaderContacts({ show = true }: HeaderContactsProps) {
if (!show) return null;
return null;
}

View File

@@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { useState } from "react";
export type CustomerOption = {
id: number | string;
name: string;
logo: string;
description?: string;
};
type HeaderCustomersProps = {
show?: boolean;
customers?: CustomerOption[];
};
export function HeaderCustomers({ show = true, customers = [] }: HeaderCustomersProps) {
const [open, setOpen] = useState(false);
const [selectedName, setSelectedName] = useState("");
const [selectedLogo, setSelectedLogo] = useState("");
const toggle = () => setOpen((v) => !v);
const select = (c: CustomerOption) => {
setSelectedName(c.name);
setSelectedLogo(c.logo);
setOpen(false);
};
if (!show || customers.length === 0) return null;
const current = customers[0];
return (
<div className="dropdown w-100 w-sm-auto">
<Link
href="#"
className="d-flex align-items-center text-body lh-1 dropdown-toggle py-sm-2"
onClick={(e) => {
e.preventDefault();
toggle();
}}
>
<img
src={selectedLogo || current.logo}
className="w-32px h-32px me-2"
alt=""
/>
<div className="me-auto me-lg-1">
<div className="fs-sm text-muted mb-1">Customer</div>
<div className="fw-semibold">{selectedName || current.name}</div>
</div>
</Link>
<div
className={
open
? "dropdown-menu dropdown-menu-lg-end w-100 w-lg-auto wmin-300 wmin-sm-350 pt-0 show"
: "dropdown-menu dropdown-menu-lg-end w-100 w-lg-auto wmin-300 wmin-sm-350 pt-0"
}
onMouseLeave={toggle}
>
<div className="d-flex align-items-center p-3">
<h6 className="fw-semibold mb-0">Customers</h6>
<Link href="#" className="ms-auto">
View all
<i className="ph ph-arrow-circle-right ms-1"></i>
</Link>
</div>
{customers.map((c) => (
<Link
href="#"
className={
selectedName === c.name
? "dropdown-item py-2 active"
: "dropdown-item py-2"
}
onClick={(e) => {
e.preventDefault();
select(c);
}}
key={c.id}
>
<img src={c.logo} className="w-32px h-32px me-2" alt="" />
<div>
<div className="fw-semibold">{c.name}</div>
{c.description && (
<div className="fs-sm text-muted">{c.description}</div>
)}
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
type MessagesProps = {
show?: boolean;
onOpenChat?: () => void;
};
export function Messages({ show = true, onOpenChat }: MessagesProps) {
if (!show) return null;
return (
<li className="nav-item">
<button
type="button"
className="navbar-nav-link navbar-nav-link-icon rounded-pill border-0 bg-transparent position-relative"
title="Open chat"
aria-label="Open chat"
onClick={onOpenChat}
>
<i className="ph ph-chat-circle"></i>
</button>
</li>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { SearchHistory } from "./SearchHistory";
import { SearchOptions } from "./SearchOptions";
type SearchProps = {
show?: boolean;
showHistory?: boolean;
showOptions?: boolean;
};
export function Search({ show = true, showHistory = true, showOptions = true }: SearchProps) {
if (!show) return null;
return (
<div
className="navbar-collapse justify-content-center flex-lg-1 order-2 order-lg-1 collapse"
id="navbar_search"
>
<div className="navbar-search flex-fill position-relative mt-2 mt-lg-0 mx-lg-3">
{showHistory && <SearchHistory />}
{showOptions && <SearchOptions />}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
export function SearchHistory() {
const [showSearchHistory, setShowSearchHistory] = useState(false);
const close = () => {
if (showSearchHistory) setShowSearchHistory(false);
};
return (
<div
className="form-control-feedback form-control-feedback-start flex-grow-1"
data-color-theme="dark"
onMouseLeave={close}
>
<input
type="text"
className="form-control bg-transparent rounded-pill"
placeholder="Search"
onClick={() => setShowSearchHistory(!showSearchHistory)}
/>
<div className="form-control-feedback-icon">
<i className="ph ph-magnifying-glass"></i>
</div>
{showSearchHistory && (
<div className="dropdown-menu w-100 show" data-color-theme="light">
<div className="dropdown-item text-muted">
<div className="text-center w-32px me-3">
<i className="ph ph-magnifying-glass"></i>
</div>
<span>Type to search...</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { useState } from "react";
export function SearchOptions() {
const [showSearchOptions, setShowSearchOptions] = useState(false);
const close = () => {
if (showSearchOptions) setShowSearchOptions(false);
};
return (
<div>
<Link
href="#"
className="navbar-nav-link align-items-center justify-content-center w-40px h-32px rounded-pill position-absolute end-0 top-50 translate-middle-y p-0 me-1"
onClick={() => setShowSearchOptions(!showSearchOptions)}
>
<i className="ph ph-faders-horizontal"></i>
</Link>
<div
className={
showSearchOptions
? "dropdown-menu w-100 p-3 show"
: "dropdown-menu w-100 p-3"
}
onMouseLeave={close}
>
<div className="d-flex align-items-center mb-3">
<h6 className="mb-0">Search options</h6>
<Link href="#" className="text-body rounded-pill ms-auto">
<i className="ph ph-clock-counter-clockwise"></i>
</Link>
</div>
<div className="mb-3">
<span className="d-block form-label">Category</span>
<label className="form-check form-check-inline">
<input type="checkbox" className="form-check-input" />
<span className="form-check-label">Invoices</span>
</label>
<label className="form-check form-check-inline">
<input type="checkbox" className="form-check-input" />
<span className="form-check-label">Files</span>
</label>
<label className="form-check form-check-inline">
<input type="checkbox" className="form-check-input" />
<span className="form-check-label">Users</span>
</label>
</div>
<div className="mb-3">
<label className="form-label">Addition</label>
<div className="input-group">
<select className="form-select w-auto flex-grow-0">
<option value="1">has</option>
<option value="2">has not</option>
</select>
<input
type="text"
className="form-control"
placeholder="Enter the word(s)"
/>
</div>
</div>
<div className="mb-3">
<label className="form-label">Status</label>
<div className="input-group">
<select className="form-select w-auto flex-grow-0">
<option value="1">is</option>
<option value="2">is not</option>
</select>
<select className="form-select">
<option value="1">Active</option>
<option value="2">Inactive</option>
<option value="3">New</option>
<option value="4">Expired</option>
<option value="5">Pending</option>
</select>
</div>
</div>
<div className="d-flex">
<button type="button" className="btn btn-light">
Reset
</button>
<div className="ms-auto">
<button type="button" className="btn btn-light">
Cancel
</button>
<button type="button" className="btn btn-primary ms-2">
Apply
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { Search } from "./Search";
export { SearchHistory } from "./SearchHistory";
export { SearchOptions } from "./SearchOptions";
export { Messages } from "./Messages";
export { BrowseApps } from "./BrowseApps";
export { HeaderContacts } from "./HeaderContacts";
export { HeaderCustomers, type CustomerOption } from "./HeaderCustomers";

25
src/chrome/index.ts Normal file
View File

@@ -0,0 +1,25 @@
export { AdminShell } from "./AdminShell";
export { LogoutButton } from "./LogoutButton";
export {
Search,
SearchHistory,
SearchOptions,
Messages,
BrowseApps,
HeaderContacts,
HeaderCustomers,
type CustomerOption,
} from "./header";
export { useChromeLabels, DEFAULT_CHROME_LABELS } from "./labels";
export type {
AdminShellProps,
ActivityFeedItem,
AppListItem,
Brand,
ChromeFeatures,
ChromeLabels,
ChromeSlots,
ChromeUser,
DbMenuItem,
MenuData,
} from "./types";

72
src/chrome/labels.ts Normal file
View File

@@ -0,0 +1,72 @@
"use client";
import { useTranslations } from "next-intl";
import type { ChromeLabels } from "./types";
/**
* Default English chrome labels. Used as fallback when next-intl namespace
* "chrome" doesn't resolve a key and no per-key override is passed in props.
*/
export const DEFAULT_CHROME_LABELS: ChromeLabels = {
navigation: "Navigation",
main: "MAIN",
dashboard: "Dashboard",
support: "Support",
settings: "Settings",
allSettings: "All Settings",
logout: "Logout",
docs: "Docs",
browseApps: "Browse apps",
viewAll: "View all",
activityTitle: "Activity",
newNotifications: "New notifications",
olderNotifications: "Older notifications",
noOlderNotifications: "No older notifications",
noNewNotifications: "No new notifications",
expandSidebar: "Expand sidebar",
collapseSidebar: "Collapse sidebar",
closeSidebar: "Close sidebar",
};
/**
* Resolve chrome labels with three-level precedence:
* 1. `overrides` prop (per-key escape hatch)
* 2. next-intl namespace "chrome" message
* 3. hardcoded English default
*
* Apps add `"chrome": { ... }` to their next-intl messages to translate.
*/
export function useChromeLabels(overrides?: Partial<ChromeLabels>): ChromeLabels {
let t: ((key: string) => string) | null = null;
try {
t = useTranslations("chrome");
} catch {
t = null;
}
const resolved = { ...DEFAULT_CHROME_LABELS };
if (t) {
for (const key of Object.keys(resolved) as (keyof ChromeLabels)[]) {
try {
const v = t(key);
// next-intl returns the key path when the message is missing — treat
// that as "no translation" so we fall back to the English default.
if (v && v !== key && !v.startsWith("chrome.")) {
resolved[key] = v;
}
} catch {
// Missing key — keep the default.
}
}
}
if (overrides) {
for (const k of Object.keys(overrides) as (keyof ChromeLabels)[]) {
const v = overrides[k];
if (v != null) resolved[k] = v;
}
}
return resolved;
}

174
src/chrome/types.ts Normal file
View File

@@ -0,0 +1,174 @@
import type { ReactNode } from "react";
// ============================================================================
// Menu data (Prisma-shaped, returned by app's getMenuItemsByType())
// ============================================================================
export type DbMenuItem = {
id: string;
key: string;
translationKey: string;
url: string;
icon: string | null;
sortOrder: number;
isActive: boolean;
isSystemRequired: boolean;
children?: DbMenuItem[];
};
export type MenuData = {
sidebar: DbMenuItem[];
topbar: DbMenuItem[];
subbar: DbMenuItem[];
};
// ============================================================================
// Browse-apps panel data (returned by app's getApps())
// ============================================================================
export type AppListItem = {
key: string;
name: string;
description?: string;
url: string;
iconClass?: string | null; // e.g. "ph-phone"
iconUrl?: string | null; // image URL (preferred when set)
iconBg?: string | null; // background utility class for iconClass square
sortOrder: number;
enabled: boolean;
};
// ============================================================================
// Activity panel data (when features.activityPanel:true)
// Optional — apps may pass `slots.activityPanel` to render their own body.
// ============================================================================
export type ActivityFeedItem = {
id: string;
actorName: string;
actorAvatarUrl?: string;
message: ReactNode; // freeform body, may contain JSX
timestamp: string; // pre-formatted display string (e.g. "2 hours ago")
group?: "new" | "older"; // default "new"
};
// ============================================================================
// Brand (replaces site-informations.json)
// ============================================================================
export type Brand = {
name: string; // "CRM"
product: string; // "GoSec CRM"
logoUrl: string; // full navbar logo
logoSmallUrl?: string; // optional compact logo
websiteUrl: string; // footer brand link
supportUrl: string; // subbar Support + footer Support link
docsUrl: string; // footer docs link
copyrightStartYear: number; // e.g. 2023
};
// ============================================================================
// User
// ============================================================================
export type ChromeUser = {
displayName: string;
email?: string;
avatarUrl?: string;
roles?: string[]; // future: role-gated menu filtering
};
// ============================================================================
// Feature toggles
// ============================================================================
export type ChromeFeatures = {
// Navbar
search?: boolean; // default true
searchHistory?: boolean; // default true
searchOptions?: boolean; // default true
browseApps?: boolean; // default true
messages?: boolean; // default true
notifications?: boolean; // default true
// Subbar
subbar?: boolean; // default true
subbarSupport?: boolean; // default true
subbarSettings?: boolean; // default true
// Page header
pageHeader?: boolean; // default true
pageHeaderCustomers?: boolean; // default false (CRM-specific)
pageHeaderContacts?: boolean; // default false (CRM-specific)
// Overlays
activityPanel?: boolean; // default false
chat?: boolean; // default false
// Footer
footer?: boolean; // default true
};
// ============================================================================
// Slots (app-rendered content; overrides built-in renderers)
// ============================================================================
export type ChromeSlots = {
pageTitle?: ReactNode;
pageHeaderExtras?: ReactNode;
subbarExtras?: ReactNode;
activityPanel?: ReactNode; // overrides built-in activity body
navbarExtras?: ReactNode;
footerExtras?: ReactNode;
};
// ============================================================================
// i18n labels (chrome strings, overridable per-key)
// Defaults shipped in labels.ts; apps may also set keys under next-intl
// namespace "chrome".
// ============================================================================
export type ChromeLabels = {
navigation: string; // sidebar header "Navigation"
main: string; // sidebar group header "MAIN"
dashboard: string; // default page title for "/"
support: string; // subbar / footer "Support"
settings: string; // subbar "Settings"
allSettings: string; // subbar dropdown "All Settings"
logout: string; // user dropdown "Logout"
docs: string; // footer "Docs"
browseApps: string; // browse-apps "Browse apps"
viewAll: string; // browse-apps "View all"
activityTitle: string; // activity panel header
newNotifications: string; // activity panel "New notifications"
olderNotifications: string; // activity panel "Older notifications"
noOlderNotifications: string; // activity panel empty older state
noNewNotifications: string; // activity panel empty new state
expandSidebar: string; // aria-label
collapseSidebar: string; // aria-label
closeSidebar: string; // aria-label
};
// ============================================================================
// AdminShell props
// ============================================================================
export type AdminShellProps = {
// Data
menus: MenuData;
apps: AppListItem[];
user: ChromeUser;
notificationCount?: number;
activity?: ActivityFeedItem[];
// Brand (required)
brand: Brand;
// Feature toggles
features?: ChromeFeatures;
// Slots
slots?: ChromeSlots;
// Behavior
onSignOut?: () => void | Promise<void>;
labels?: Partial<ChromeLabels>;
children: ReactNode;
};

View File

@@ -6,6 +6,7 @@
export * from "./layout/index";
export * from "./shell/index";
export * from "./chrome/index";
export * from "./data/index";
export * from "./forms/index";
export * from "./feedback/index";