mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 07:08:20 +00:00
feat: implement custom 404 error page and internationalized navigation suggestions for backoffice and portal apps
This commit is contained in:
@@ -15,6 +15,19 @@ export const am: Translations = {
|
||||
skipToContent: "ወደ ዋናው ይዘት ዝለል",
|
||||
},
|
||||
|
||||
notFound: {
|
||||
kicker: 'ስህተት 404 — ገጹ አልተገኘም',
|
||||
title: 'ከካርታው ውጭ ወጥተዋል',
|
||||
description:
|
||||
'ይህ ገጽ የለም፣ ተዛውሯል፣ ወይም ለመለያዎ አይገኝም። አገናኙም ጊዜው ያለፈበት ሊሆን ይችላል።',
|
||||
home: 'ወደ ዳሽቦርድ ተመለስ',
|
||||
back: 'ተመለስ',
|
||||
suggestions: 'ወይም ወደ ሌላ ጠቃሚ ቦታ ይሂዱ',
|
||||
dashboardDesc: 'ወረፋዎችዎ እና የዛሬው ሥራዎ',
|
||||
analyticsDesc: 'መጠኖች፣ የማጠናቀቂያ ጊዜ እና አዝማሚያዎች',
|
||||
profileDesc: 'መለያዎ እና ምርጫዎችዎ',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
||||
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
||||
|
||||
@@ -15,6 +15,21 @@ export const en = {
|
||||
skipToContent: 'Skip to main content',
|
||||
},
|
||||
|
||||
// The page a wrong URL lands on. Labels for its suggested destinations come
|
||||
// from `nav`, so they cannot drift from the sidebar.
|
||||
notFound: {
|
||||
kicker: 'Error 404 — page not found',
|
||||
title: "You've drifted off the chart",
|
||||
description:
|
||||
'This page does not exist, has moved, or is not available to your account. The link may be out of date.',
|
||||
home: 'Back to dashboard',
|
||||
back: 'Go back',
|
||||
suggestions: 'Or head somewhere useful',
|
||||
dashboardDesc: "Your queues and today's workload",
|
||||
analyticsDesc: 'Volumes, turnaround and trends',
|
||||
profileDesc: 'Your account and preferences',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
RouterProvider,
|
||||
@@ -12,7 +13,12 @@ import {
|
||||
RequirePermission,
|
||||
LICENSE_PERMISSIONS as P,
|
||||
} from '@ema-platform/auth';
|
||||
import { ThemeGallery } from '@ema-platform/ui';
|
||||
import { NotFoundPage, ThemeGallery } from '@ema-platform/ui';
|
||||
import {
|
||||
IconChartBar,
|
||||
IconLayoutDashboard,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
@@ -67,6 +73,41 @@ const guard = (anyOf: string[], element: ReactNode) => (
|
||||
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
|
||||
);
|
||||
|
||||
/**
|
||||
* Only the destinations every signed-in officer can reach — suggesting a
|
||||
* permission-gated queue would swap one dead end for another. Labels come from
|
||||
* `nav`, the same keys the sidebar uses, so the two cannot drift apart.
|
||||
*/
|
||||
function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NotFoundPage
|
||||
homePath="/dashboard"
|
||||
links={[
|
||||
{
|
||||
label: t('nav.dashboard'),
|
||||
description: t('notFound.dashboardDesc'),
|
||||
to: '/dashboard',
|
||||
icon: IconLayoutDashboard,
|
||||
},
|
||||
{
|
||||
label: t('nav.analytics'),
|
||||
description: t('notFound.analyticsDesc'),
|
||||
to: '/analytics',
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
label: t('nav.profile'),
|
||||
description: t('notFound.profileDesc'),
|
||||
to: '/profile',
|
||||
icon: IconUserCircle,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <AuthLayout />,
|
||||
@@ -163,8 +204,10 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/404', element: <div>Page not found</div> },
|
||||
{ path: '*', element: <Navigate to="/404" replace /> },
|
||||
// Standalone by design — a wrong URL should read as a wrong URL, not bounce
|
||||
// an officer through the layout of a page they never asked for.
|
||||
{ path: '/404', element: <NotFound /> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
]);
|
||||
|
||||
export function AppRouter() {
|
||||
|
||||
@@ -14,6 +14,20 @@ export const am: Translations = {
|
||||
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
|
||||
},
|
||||
|
||||
notFound: {
|
||||
kicker: 'ስህተት 404 — ገጹ አልተገኘም',
|
||||
title: 'ከካርታው ውጭ ወጥተዋል',
|
||||
description:
|
||||
'ይህ ገጽ የለም፣ ተዛውሯል፣ ወይም ለመለያዎ አይገኝም። አገናኙም ጊዜው ያለፈበት ሊሆን ይችላል።',
|
||||
home: 'ወደ ዳሽቦርድ ተመለስ',
|
||||
back: 'ተመለስ',
|
||||
suggestions: 'ወይም ወደ ሌላ ጠቃሚ ቦታ ይሂዱ',
|
||||
dashboardDesc: 'ማመልከቻዎችዎ፣ ፈቃዶችዎ እና ቀጣይ እርምጃዎች',
|
||||
documentsDesc: 'የጫኗቸው ሁሉም ሰነዶች',
|
||||
notificationsDesc: 'ስላቀረቧቸው ጉዳዮች ማሻሻያዎች',
|
||||
supportDesc: 'ለእገዛ ባለሥልጣኑን ያግኙ',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||
|
||||
@@ -14,6 +14,22 @@ export const en = {
|
||||
skipToContent: 'Skip to main content',
|
||||
},
|
||||
|
||||
// The page a wrong URL lands on. Labels for its suggested destinations come
|
||||
// from `nav`, so they cannot drift from the sidebar.
|
||||
notFound: {
|
||||
kicker: 'Error 404 — page not found',
|
||||
title: "You've drifted off the chart",
|
||||
description:
|
||||
'This page does not exist, has moved, or is not available to your account. The link may be out of date.',
|
||||
home: 'Back to dashboard',
|
||||
back: 'Go back',
|
||||
suggestions: 'Or head somewhere useful',
|
||||
dashboardDesc: 'Your applications, licences and next steps',
|
||||
documentsDesc: 'Everything you have uploaded',
|
||||
notificationsDesc: 'Updates on what you have filed',
|
||||
supportDesc: 'Reach the authority for help',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
import { ThemeGallery } from "@ema-platform/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NotFoundPage, ThemeGallery } from "@ema-platform/ui";
|
||||
import {
|
||||
IconBell,
|
||||
IconFolder,
|
||||
IconLayoutDashboard,
|
||||
IconLifebuoy,
|
||||
} from "@tabler/icons-react";
|
||||
import { PortalLayout } from "./layouts/PortalLayout";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { LandingRoute } from "./components/LandingRoute";
|
||||
@@ -57,6 +64,47 @@ import { WaiverPage } from "./features/waiver/pages/WaiverPage";
|
||||
|
||||
import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage";
|
||||
|
||||
/**
|
||||
* Only routes with no permission gate, so a suggestion can never land the
|
||||
* applicant on a second refusal. Labels come from `nav`, the same keys the
|
||||
* sidebar uses, so the two cannot drift apart.
|
||||
*/
|
||||
function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NotFoundPage
|
||||
homePath="/dashboard"
|
||||
links={[
|
||||
{
|
||||
label: t("nav.dashboard"),
|
||||
description: t("notFound.dashboardDesc"),
|
||||
to: "/dashboard",
|
||||
icon: IconLayoutDashboard,
|
||||
},
|
||||
{
|
||||
label: t("nav.documents"),
|
||||
description: t("notFound.documentsDesc"),
|
||||
to: "/documents",
|
||||
icon: IconFolder,
|
||||
},
|
||||
{
|
||||
label: t("nav.notifications"),
|
||||
description: t("notFound.notificationsDesc"),
|
||||
to: "/notifications",
|
||||
icon: IconBell,
|
||||
},
|
||||
{
|
||||
label: t("nav.support"),
|
||||
description: t("notFound.supportDesc"),
|
||||
to: "/support",
|
||||
icon: IconLifebuoy,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public landing page — institutional overview + role-based entry points.
|
||||
{ path: "/", element: <LandingRoute /> },
|
||||
@@ -483,7 +531,11 @@ export const router = createBrowserRouter([
|
||||
element: <Navigate to="/vessel-ownership-transfer" replace />,
|
||||
},
|
||||
|
||||
// A typo'd URL should not maroon a signed-in user on the marketing page —
|
||||
// ProtectedRoute sends anonymous visitors on to / (landing) instead.
|
||||
{ path: "*", element: <Navigate to="/dashboard" replace /> },
|
||||
// A typo'd URL used to be redirected silently to the dashboard, which read as
|
||||
// the app ignoring the link rather than as a missing page. Say so instead,
|
||||
// and offer the way back — the primary action is /dashboard, so a signed-in
|
||||
// user is one click from where the redirect used to dump them, and
|
||||
// ProtectedRoute still sends anonymous visitors on to / (landing).
|
||||
{ path: "/404", element: <NotFound /> },
|
||||
{ path: "*", element: <NotFound /> },
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from "./lib/feedback/notify";
|
||||
export * from "./lib/feedback/FeatureUnavailable";
|
||||
export * from "./lib/feedback/EmptyState";
|
||||
export * from "./lib/feedback/ErrorState";
|
||||
export * from "./lib/feedback/NotFoundPage";
|
||||
export * from "./lib/feedback/PageLoader";
|
||||
export * from "./lib/feedback/StatusBadge";
|
||||
export * from "./lib/components/MaritimeLoader";
|
||||
|
||||
345
libs/ui/src/lib/feedback/NotFoundPage.tsx
Normal file
345
libs/ui/src/lib/feedback/NotFoundPage.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Code,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconChevronRight,
|
||||
IconLifebuoy,
|
||||
type Icon,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './not-found.css';
|
||||
|
||||
export interface NotFoundLink {
|
||||
label: string;
|
||||
/** One line on why someone would go here instead. */
|
||||
description?: string;
|
||||
to: string;
|
||||
icon?: Icon;
|
||||
}
|
||||
|
||||
export interface NotFoundPageProps {
|
||||
/** Where the primary action goes. Each app passes its own signed-in home. */
|
||||
homePath?: string;
|
||||
homeLabel?: string;
|
||||
/**
|
||||
* A handful of real destinations. A dead end that only says "go home" makes
|
||||
* the visitor guess; naming the pages they probably wanted does not.
|
||||
*/
|
||||
links?: NotFoundLink[];
|
||||
/** Anything extra below the actions — a support link, a contact line. */
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Fills the viewport. Turn this off when the page renders inside an app
|
||||
* layout, where the shell already owns the height.
|
||||
*/
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
/** 16 bezel ticks; every fourth is a cardinal point and drawn longer. */
|
||||
const TICKS = Array.from({ length: 16 }, (_, i) => i * 22.5);
|
||||
|
||||
const WAVELENGTH = 1440;
|
||||
|
||||
/**
|
||||
* Two wavelengths of swell as one continuous path.
|
||||
*
|
||||
* One path rather than two abutting copies: the band slides by exactly one
|
||||
* wavelength and loops, so it needs twice the width it shows, and two filled
|
||||
* shapes meeting at a shared edge leave an anti-aliased hairline down the
|
||||
* screen. The curve is symmetric about its midpoint, so the repeat has no kink.
|
||||
*/
|
||||
const WAVE = (() => {
|
||||
const cycle = (x: number) =>
|
||||
` C${x + 120},48 ${x + 240},48 ${x + 360},72` +
|
||||
` C${x + 480},96 ${x + 600},96 ${x + 720},72` +
|
||||
` C${x + 840},48 ${x + 960},48 ${x + 1080},72` +
|
||||
` C${x + 1200},96 ${x + 1320},96 ${x + 1440},72`;
|
||||
|
||||
return `M0,72${cycle(0)}${cycle(WAVELENGTH)} L${WAVELENGTH * 2},140 L0,140 Z`;
|
||||
})();
|
||||
|
||||
/** The compass that cannot find the page — the illustration, not information. */
|
||||
function LostCompass() {
|
||||
return (
|
||||
<svg className="ema-nf__compass" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="ema-nf-rose" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="var(--mantine-primary-color-filled)" />
|
||||
<stop offset="100%" stopColor="var(--mantine-color-teal-6)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Dial */}
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="92"
|
||||
fill="var(--mantine-color-body)"
|
||||
stroke="var(--mantine-color-default-border)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="92"
|
||||
fill="color-mix(in srgb, var(--mantine-primary-color-filled) 5%, transparent)"
|
||||
/>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="92"
|
||||
fill="none"
|
||||
stroke="url(#ema-nf-rose)"
|
||||
strokeWidth="3"
|
||||
opacity="0.55"
|
||||
/>
|
||||
|
||||
{/* Bezel: ticks turn as one group, slowly, like a gimbal settling. */}
|
||||
<g className="ema-nf__bezel">
|
||||
{TICKS.map((angle) => {
|
||||
const cardinal = angle % 90 === 0;
|
||||
return (
|
||||
<line
|
||||
key={angle}
|
||||
x1="100"
|
||||
y1={cardinal ? 14 : 18}
|
||||
x2="100"
|
||||
y2="26"
|
||||
stroke="var(--mantine-primary-color-filled)"
|
||||
strokeWidth={cardinal ? 3 : 1.5}
|
||||
strokeLinecap="round"
|
||||
opacity={cardinal ? 0.75 : 0.35}
|
||||
transform={`rotate(${angle} 100 100)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="72"
|
||||
fill="none"
|
||||
stroke="var(--mantine-primary-color-filled)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="1 10"
|
||||
strokeLinecap="round"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Rose: the four points, north picked out against the rest. */}
|
||||
<g opacity="0.9">
|
||||
<path d="M100 30 L112 94 L100 100 L88 94 Z" fill="url(#ema-nf-rose)" />
|
||||
<path
|
||||
d="M100 170 L112 106 L100 100 L88 106 Z"
|
||||
fill="var(--mantine-primary-color-filled)"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<path
|
||||
d="M170 100 L106 112 L100 100 L106 88 Z"
|
||||
fill="var(--mantine-primary-color-filled)"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M30 100 L94 112 L100 100 L94 88 Z"
|
||||
fill="var(--mantine-primary-color-filled)"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Needle */}
|
||||
<g className="ema-nf__needle">
|
||||
<path d="M100 44 L107 100 L100 108 L93 100 Z" fill="var(--mantine-color-red-6)" />
|
||||
<path
|
||||
d="M100 156 L107 100 L100 92 L93 100 Z"
|
||||
fill="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
</g>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="7"
|
||||
fill="var(--mantine-color-body)"
|
||||
stroke="var(--mantine-primary-color-filled)"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The page a wrong URL lands on.
|
||||
*
|
||||
* Both apps used to swallow a bad path — the portal redirected it to the
|
||||
* dashboard, the backoffice rendered a bare `<div>`. Neither told anyone what
|
||||
* had happened, so a typo'd or stale link looked like the app misbehaving.
|
||||
* This says plainly what was asked for, that it does not exist, and where to
|
||||
* go instead.
|
||||
*/
|
||||
export function NotFoundPage({
|
||||
homePath = '/',
|
||||
homeLabel,
|
||||
links,
|
||||
footer,
|
||||
fullHeight = true,
|
||||
}: NotFoundPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<Box className="ema-nf" mih={fullHeight ? '100dvh' : 480}>
|
||||
<div className="ema-nf__backdrop" aria-hidden="true">
|
||||
<div className="ema-nf__grid" />
|
||||
<div className="ema-nf__glow ema-nf__glow--primary" />
|
||||
<div className="ema-nf__glow ema-nf__glow--accent" />
|
||||
|
||||
<div className="ema-nf__sea">
|
||||
{/* The viewBox shows one wavelength; the path carries two, and the
|
||||
band slides across the difference. */}
|
||||
<svg viewBox={`0 0 ${WAVELENGTH} 140`} preserveAspectRatio="none">
|
||||
<path
|
||||
className="ema-nf__swell ema-nf__swell--back"
|
||||
d={WAVE}
|
||||
fill="var(--mantine-primary-color-filled)"
|
||||
opacity="0.1"
|
||||
transform="translate(0 20)"
|
||||
/>
|
||||
<path
|
||||
className="ema-nf__swell"
|
||||
d={WAVE}
|
||||
fill="var(--mantine-primary-color-filled)"
|
||||
opacity="0.16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Center mih="inherit" pos="relative" style={{ zIndex: 1 }} py={64} px="md">
|
||||
<Container size="sm" p={0} w="100%">
|
||||
<Stack align="center" gap="lg" className="ema-nf__enter">
|
||||
{/* Decorative: the heading below carries the meaning for anyone
|
||||
who is not looking at it. */}
|
||||
<Group gap={0} className="ema-nf__digits" aria-hidden="true">
|
||||
<span className="ema-nf__digit">4</span>
|
||||
<LostCompass />
|
||||
<span className="ema-nf__digit">4</span>
|
||||
</Group>
|
||||
|
||||
<Stack align="center" gap="xs">
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: '0.14em' }}
|
||||
>
|
||||
{t('notFound.kicker', 'Error 404 — page not found')}
|
||||
</Text>
|
||||
<Title order={1} ta="center" fz={{ base: 26, sm: 34 }}>
|
||||
{t('notFound.title', "You've drifted off the chart")}
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center" maw={520}>
|
||||
{t(
|
||||
'notFound.description',
|
||||
'This page does not exist, has moved, or is not available to your account. The link may be out of date.',
|
||||
)}
|
||||
</Text>
|
||||
<Code
|
||||
mt={4}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{pathname}
|
||||
</Code>
|
||||
</Stack>
|
||||
|
||||
<Group justify="center" gap="sm">
|
||||
<Button
|
||||
component={Link}
|
||||
to={homePath}
|
||||
size="md"
|
||||
leftSection={<IconLifebuoy size={18} />}
|
||||
>
|
||||
{homeLabel ?? t('notFound.home', 'Back to dashboard')}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={18} />}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
{t('notFound.back', 'Go back')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{links && links.length > 0 && (
|
||||
<Stack gap="sm" w="100%" mt={4}>
|
||||
<Text fz="sm" fw={600} c="dimmed" ta="center">
|
||||
{t('notFound.suggestions', 'Or head somewhere useful')}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{links.map((link) => {
|
||||
const LinkIcon = link.icon;
|
||||
return (
|
||||
<Paper
|
||||
key={link.to}
|
||||
component={Link}
|
||||
to={link.to}
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
className="ema-nf__link"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{LinkIcon && (
|
||||
<ThemeIcon size={36} radius="md" variant="light">
|
||||
<LinkIcon size={20} stroke={1.6} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fz="sm" fw={600} c="var(--mantine-color-text)">
|
||||
{link.label}
|
||||
</Text>
|
||||
{link.description && (
|
||||
<Text fz="xs" c="dimmed" lineClamp={1}>
|
||||
{link.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
stroke={2}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
235
libs/ui/src/lib/feedback/not-found.css
Normal file
235
libs/ui/src/lib/feedback/not-found.css
Normal file
@@ -0,0 +1,235 @@
|
||||
/* Page-not-found surface — scoped under .ema-nf so nothing leaks into either
|
||||
app. Colours resolve to Mantine variables (and the semantic tokens layered
|
||||
over them), so the whole scene follows the colour scheme for free. */
|
||||
|
||||
.ema-nf {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--ema-surface-page, var(--mantine-color-body));
|
||||
}
|
||||
|
||||
/* --- Ambient backdrop -----------------------------------------------------
|
||||
A nautical chart under the content: a faint grid, two soft light wells, and
|
||||
a horizon of drifting swell along the bottom edge. All decorative, all
|
||||
behind the content, none of it hit-testable. */
|
||||
|
||||
.ema-nf__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ema-nf__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
color: var(--mantine-color-default-border);
|
||||
opacity: 0.5;
|
||||
/* Fades the grid out toward the edges so it reads as texture, not a table. */
|
||||
mask-image: radial-gradient(ellipse 90% 70% at 50% 42%, #000 15%, transparent 78%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 42%, #000 15%, transparent 78%);
|
||||
}
|
||||
|
||||
.ema-nf__glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ema-nf__glow--primary {
|
||||
inset-block-start: -14%;
|
||||
inset-inline-start: 50%;
|
||||
width: min(720px, 90vw);
|
||||
aspect-ratio: 1;
|
||||
translate: -50% 0;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--mantine-primary-color-filled) 22%, transparent) 0%,
|
||||
transparent 68%
|
||||
);
|
||||
}
|
||||
|
||||
.ema-nf__glow--accent {
|
||||
inset-block-end: -20%;
|
||||
inset-inline-end: -8%;
|
||||
width: min(520px, 70vw);
|
||||
aspect-ratio: 1;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--mantine-color-teal-6) 18%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .ema-nf__glow {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* --- Swell ---------------------------------------------------------------- */
|
||||
|
||||
.ema-nf__sea {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
height: clamp(96px, 16vh, 168px);
|
||||
width: 100%;
|
||||
/* Dissolves into the page instead of meeting it at a hard waterline. */
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 70%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 70%);
|
||||
}
|
||||
|
||||
.ema-nf__sea svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Each band is one path two wavelengths wide; sliding it by exactly one
|
||||
wavelength (1440 user units) and looping is seamless. */
|
||||
.ema-nf__swell {
|
||||
animation: ema-nf-drift 18s linear infinite;
|
||||
}
|
||||
|
||||
.ema-nf__swell--back {
|
||||
animation-duration: 27s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
@keyframes ema-nf-drift {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-1440px);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Compass -------------------------------------------------------------- */
|
||||
|
||||
.ema-nf__compass {
|
||||
width: clamp(104px, 17vw, 168px);
|
||||
aspect-ratio: 1;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ema-nf__bezel {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: ema-nf-bezel 40s linear infinite;
|
||||
}
|
||||
|
||||
/* A compass that has lost its bearing: it sweeps, overshoots, and settles —
|
||||
never resolving. The metaphor is the whole point of the illustration. */
|
||||
.ema-nf__needle {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: ema-nf-search 11s cubic-bezier(0.5, 0, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes ema-nf-bezel {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ema-nf-search {
|
||||
0% {
|
||||
transform: rotate(-24deg);
|
||||
}
|
||||
22% {
|
||||
transform: rotate(142deg);
|
||||
}
|
||||
34% {
|
||||
transform: rotate(118deg);
|
||||
}
|
||||
58% {
|
||||
transform: rotate(292deg);
|
||||
}
|
||||
70% {
|
||||
transform: rotate(268deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(336deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Numerals ------------------------------------------------------------- */
|
||||
|
||||
.ema-nf__digits {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(4px, 1.4vw, 16px);
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.ema-nf__digit {
|
||||
font-size: clamp(88px, 17vw, 168px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
/* Painted colour is the fallback; the clip only applies where supported. */
|
||||
color: var(--mantine-primary-color-filled);
|
||||
background-image: linear-gradient(
|
||||
160deg,
|
||||
var(--mantine-primary-color-filled) 0%,
|
||||
var(--mantine-primary-color-filled) 38%,
|
||||
var(--mantine-color-teal-6) 125%
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* --- Entrance & quick links ----------------------------------------------- */
|
||||
|
||||
.ema-nf__enter {
|
||||
animation: ema-nf-rise 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes ema-nf-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ema-nf__link {
|
||||
transition:
|
||||
transform 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||
border-color 180ms ease;
|
||||
}
|
||||
|
||||
.ema-nf__link:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
border-color: var(--mantine-primary-color-light-color);
|
||||
}
|
||||
|
||||
/* Motion here is atmosphere, never information — dropping all of it costs the
|
||||
page nothing. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ema-nf__swell,
|
||||
.ema-nf__bezel,
|
||||
.ema-nf__needle,
|
||||
.ema-nf__enter,
|
||||
.ema-nf__link {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.ema-nf__link:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user