Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ runtime/
uploads/
artifacts/
reports/
/marketing-materials/
*.db
*.sqlite
*.sqlite3
Expand Down
15 changes: 15 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ corepack pnpm web:browser:install:ci
corepack pnpm web:test:e2e:ci
```

For the everyday edit-refresh loop, run the split local stack from the
repository root:

```text
corepack pnpm dev:infra # Docker: PostgreSQL, Redis, MinIO, Mailpit, OTEL
corepack pnpm dev:api # watched API on http://127.0.0.1:3000
corepack pnpm dev:web # Vite + React Refresh on http://127.0.0.1:5173
```

Open `http://127.0.0.1:5173/vi-VN/workspace`. Vite proxies `/v1`, `/v3`, and
`/health` to the API, so editing `apps/web/src/*` updates the browser without
rebuilding a container. Do not use the pilot/production Caddy URL for this
loop; it serves a built bundle and has no HMR. `dev:stack` prints the same
three-terminal instructions without leaving background processes behind.

The root `web:test:e2e` command builds public workspace dependencies first, runs the production
preview desktop/mobile suite, and then starts the Vite development server for its browser
regression. `web:test:e2e:preview` and `web:test:e2e:dev` expose those lanes independently. Local
Expand Down
137 changes: 110 additions & 27 deletions apps/web/src/components/application-rail.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import wordmarkUrl from '@databreeze/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png';
import brandMarkUrl from '@databreeze/design-tokens/brand/generated/web/install-icon-192.png';
import { formatMessageV1 } from '@databreeze/i18n/v1';
import { useEffect } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { getFeatureRegistration } from '../app/feature-registry.ts';
import { appMessage } from '../app/messages.ts';
import type { NavigationItem } from '../app/navigation.ts';
import {
udwPrimaryNavLabelV1,
type UdwPrimaryNavItemV1,
} from '../app/unified-primary-navigation.ts';
import { BellIcon, MenuIcon, SearchIcon, XIcon } from './icons.tsx';

export interface ApplicationRailProperties {
readonly collapsed?: boolean;
readonly isMobile?: boolean;
readonly items: readonly UdwPrimaryNavItemV1[];
readonly locale: 'en' | 'vi-VN';
readonly mobileOpen: boolean;
readonly onCollapsedChange?: (collapsed: boolean) => void;
readonly onMobileOpenChange: (open: boolean) => void;
readonly secondaryItems?: readonly NavigationItem[];
}

function RailIcon({ itemKey }: { readonly itemKey: UdwPrimaryNavItemV1['key'] }) {
Expand Down Expand Up @@ -56,31 +64,29 @@ function RailIcon({ itemKey }: { readonly itemKey: UdwPrimaryNavItemV1['key'] })
);
}

function CloseIcon() {
return (
<svg
aria-hidden="true"
fill="none"
height="20"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
viewBox="0 0 24 24"
width="20"
>
<path d="m6 6 12 12M18 6 6 18" />
</svg>
);
function SecondaryIcon({ itemKey }: { readonly itemKey: NavigationItem['key'] }) {
if (itemKey === 'inbox') return <BellIcon />;
if (itemKey === 'reviews') return <SearchIcon />;
return <MenuIcon />;
}

function secondaryLabel(locale: 'en' | 'vi-VN', item: NavigationItem): string {
const registration = getFeatureRegistration(item.key);
return registration.messageKey === undefined
? item.key
: formatMessageV1(locale, registration.messageKey);
}

/** WEB-002/013/014/022: compact, build-time registered primary navigation. */
export function ApplicationRail({
collapsed = false,
isMobile = false,
items,
locale,
mobileOpen,
onCollapsedChange = () => undefined,
onMobileOpenChange,
secondaryItems = [],
}: ApplicationRailProperties) {
useEffect(() => {
if (!isMobile || !mobileOpen) return undefined;
Expand All @@ -92,35 +98,79 @@ export function ApplicationRail({
return () => globalThis.removeEventListener('keydown', closeOnEscape);
}, [isMobile, mobileOpen, onMobileOpenChange]);

const effectivelyCollapsed = isMobile ? false : collapsed;
const collapseLabel =
locale === 'vi-VN'
? effectivelyCollapsed
? 'Mở rộng thanh bên'
: 'Thu gọn thanh bên'
: effectivelyCollapsed
? 'Expand sidebar'
: 'Collapse sidebar';
const workspaceLabel = locale === 'vi-VN' ? 'Không gian làm việc' : 'Workspace';
const toolsLabel = locale === 'vi-VN' ? 'Công cụ' : 'Tools';

return (
<nav
aria-label={appMessage(locale, 'nav.label')}
className={`application-rail${isMobile ? ' application-rail--mobile' : ''}${
mobileOpen ? ' is-mobile-open' : ''
}`}
data-collapsed={effectivelyCollapsed}
hidden={isMobile && !mobileOpen}
id="primary-navigation"
>
<Link
aria-label="DataBreeze"
className="application-rail__brand"
to={`/${locale}/dashboards`}
>
<span className="application-rail__brand-mark" aria-hidden="true">
<img alt="" height="50" src={wordmarkUrl} width="204" />
</span>
</Link>
<div className="application-rail__header">
<Link
aria-label="DataBreeze"
className="application-rail__brand"
to={`/${locale}/dashboards`}
>
<span className="application-rail__brand-mark" aria-hidden="true">
<img
alt=""
className="application-rail__brand-wordmark"
height="50"
src={wordmarkUrl}
width="204"
/>
<img
alt=""
className="application-rail__brand-icon"
height="192"
src={brandMarkUrl}
width="192"
/>
</span>
</Link>
{!isMobile ? (
<button
aria-expanded={!effectivelyCollapsed}
aria-label={collapseLabel}
className="application-rail__collapse"
onClick={() => onCollapsedChange(!effectivelyCollapsed)}
title={collapseLabel}
type="button"
>
<MenuIcon />
</button>
) : null}
</div>
{isMobile ? (
<button
aria-label={appMessage(locale, 'nav.close')}
className="application-rail__mobile-close"
onClick={() => onMobileOpenChange(false)}
type="button"
>
<CloseIcon />
<XIcon />
</button>
) : null}
<ul className="application-rail__items">
<p className="application-rail__group-label">{workspaceLabel}</p>
<ul
aria-label={workspaceLabel}
className="application-rail__items application-rail__items--primary"
>
{items.map((item) => {
const label = udwPrimaryNavLabelV1(locale, item.key);
return (
Expand All @@ -130,6 +180,9 @@ export function ApplicationRail({
isActive ? 'application-rail__link is-active' : 'application-rail__link'
}
end
onClick={() => {
if (isMobile) onMobileOpenChange(false);
}}
title={label}
to={`/${locale}/${item.path}`}
>
Expand All @@ -140,6 +193,36 @@ export function ApplicationRail({
);
})}
</ul>
{secondaryItems.length > 0 ? (
<div className="application-rail__secondary">
<p className="application-rail__group-label">{toolsLabel}</p>
<ul
aria-label={toolsLabel}
className="application-rail__items application-rail__items--secondary"
>
{secondaryItems.map((item) => {
const label = secondaryLabel(locale, item);
return (
<li key={item.key}>
<NavLink
className={({ isActive }) =>
isActive ? 'application-rail__link is-active' : 'application-rail__link'
}
onClick={() => {
if (isMobile) onMobileOpenChange(false);
}}
title={label}
to={`/${locale}/${item.path}`}
>
<SecondaryIcon itemKey={item.key} />
<span className="application-rail__label">{label}</span>
</NavLink>
</li>
);
})}
</ul>
</div>
) : null}
</nav>
);
}
60 changes: 37 additions & 23 deletions apps/web/src/components/shell-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,59 @@ import { useEffect, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { LocaleProvider, normalizeRouteLocale } from '../app/locale-context.tsx';
import { appMessage } from '../app/messages.ts';
import type { WebAccessContext } from '../app/navigation.ts';
import { filterNavigationItems, type WebAccessContext } from '../app/navigation.ts';
import { UDW_PRIMARY_NAV_ITEMS_V1 } from '../app/unified-primary-navigation.ts';
import { DashboardWorkspace } from '../features/dashboards/dashboard-workspace.tsx';
import { createAuthApiV1 } from '../features/auth/auth-api.ts';
import { currentAuthBootstrapV1 } from '../features/auth/auth-session.ts';
import { ApplicationRail } from './application-rail.tsx';
import {
readSidebarCompactPreference,
writeSidebarCompactPreference,
} from './sidebar-preference.ts';
import { WorkspaceTopbar } from './workspace-topbar.tsx';
import '../styles/workspace-shell.css';

const MOBILE_QUERY = '(max-width: 767px)';
const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023px)';

function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() =>
typeof globalThis.matchMedia === 'function'
? globalThis.matchMedia(MOBILE_QUERY).matches
: false,
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof globalThis.matchMedia === 'function' ? globalThis.matchMedia(query).matches : false,
);

useEffect(() => {
if (typeof globalThis.matchMedia !== 'function') return undefined;
const mediaQuery = globalThis.matchMedia(MOBILE_QUERY);
const update = (event: MediaQueryListEvent) => setIsMobile(event.matches);
setIsMobile(mediaQuery.matches);
const mediaQuery = globalThis.matchMedia(query);
const update = (event: MediaQueryListEvent) => setMatches(event.matches);
setMatches(mediaQuery.matches);
mediaQuery.addEventListener('change', update);
return () => mediaQuery.removeEventListener('change', update);
}, []);
}, [query]);

return isMobile;
return matches;
}

/** WEB-002/013/014/022: shared shell keeps routes and server-authorized feature boundaries intact. */
export function ShellLayout({ accessContext }: { readonly accessContext: WebAccessContext }) {
void accessContext;
const { locale: routeLocale } = useParams();
const locale = normalizeRouteLocale(routeLocale);
const location = useLocation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const isMobile = useMediaQuery(MOBILE_QUERY);
const isTablet = useMediaQuery(TABLET_QUERY);
const [navigationOpen, setNavigationOpen] = useState(false);
const [sidebarPreference, setSidebarPreference] = useState<boolean | undefined>(() =>
readSidebarCompactPreference(),
);
const bootstrap = currentAuthBootstrapV1();
const logicalPath = location.pathname.split('/').filter(Boolean).slice(1).join('/');
const isDashboardWorkspace = logicalPath === 'dashboards';
const isAnalysisWorkspace = logicalPath === 'analysis';
const sidebarCollapsed = !isMobile && (sidebarPreference ?? isTablet);
const secondaryKeys = new Set(['inbox', 'reviews', 'administration']);
const secondaryItems = filterNavigationItems(accessContext).filter((item) =>
secondaryKeys.has(item.key),
);

useEffect(() => {
setNavigationOpen(false);
Expand All @@ -54,7 +65,10 @@ export function ShellLayout({ accessContext }: { readonly accessContext: WebAcce
<a className="skip-link" href="#main-content">
{appMessage(locale, 'skip.main')}
</a>
<div className={`app-shell${isDashboardWorkspace ? ' app-shell--dashboard' : ''}`}>
<div
className={`app-shell${isDashboardWorkspace ? ' app-shell--dashboard' : ''}`}
data-sidebar-collapsed={sidebarCollapsed}
>
<WorkspaceTopbar
{...(bootstrap === undefined ? {} : { bootstrap })}
dashboardMode={isDashboardWorkspace}
Expand All @@ -70,24 +84,24 @@ export function ShellLayout({ accessContext }: { readonly accessContext: WebAcce
}}
/>
<ApplicationRail
collapsed={sidebarCollapsed}
isMobile={isMobile}
items={UDW_PRIMARY_NAV_ITEMS_V1}
locale={locale}
mobileOpen={navigationOpen}
onCollapsedChange={(collapsed) => {
setSidebarPreference(collapsed);
writeSidebarCompactPreference(collapsed);
}}
onMobileOpenChange={setNavigationOpen}
secondaryItems={secondaryItems}
/>
<main
className={`main-workspace${isDashboardWorkspace ? ' main-workspace--dashboard' : ''}`}
className={`main-workspace${isDashboardWorkspace ? ' main-workspace--dashboard' : ''}${isAnalysisWorkspace ? ' main-workspace--analysis' : ''}`}
id="main-content"
tabIndex={-1}
>
{isDashboardWorkspace ? (
<DashboardWorkspace locale={locale}>
<Outlet />
</DashboardWorkspace>
) : (
<Outlet />
)}
<Outlet />
</main>
</div>
</LocaleProvider>
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/sidebar-preference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const SIDEBAR_COMPACT_KEY = 'databreeze.sidebar.compact.v1';

/** WEB-013: stores presentation only; never authority, tenant, or resource identity. */
export function readSidebarCompactPreference(): boolean | undefined {
try {
const value = globalThis.localStorage?.getItem(SIDEBAR_COMPACT_KEY);
return value === 'true' ? true : value === 'false' ? false : undefined;
} catch {
return undefined;
}
}

/** WEB-013: device-local, bounded and content-free preference. */
export function writeSidebarCompactPreference(compact: boolean): void {
try {
globalThis.localStorage?.setItem(SIDEBAR_COMPACT_KEY, String(compact));
} catch {
// A blocked storage provider must not make navigation unavailable.
}
}
Loading
Loading