mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 23:28:12 +00:00
frontend scalfolding
This commit is contained in:
21
apps/portal/src/app/components/ColorSchemeToggle.tsx
Normal file
21
apps/portal/src/app/components/ColorSchemeToggle.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
|
||||
import { IconSun, IconMoon } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ColorSchemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const isDark = computed === 'dark';
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('common.toggleTheme')}
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
>
|
||||
{isDark ? <IconSun size={20} /> : <IconMoon size={20} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
67
apps/portal/src/app/components/EmptyState.tsx
Normal file
67
apps/portal/src/app/components/EmptyState.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button, Paper, Stack, Text } from '@mantine/core';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Kept for API compatibility; the illustration is used as the primary visual. */
|
||||
icon?: Icon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: { label: string; onClick: () => void; icon?: ReactNode };
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<Paper p="xl" withBorder>
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<EmptyIllustration />
|
||||
<Text fw={600} fz="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text c="dimmed" size="sm" ta="center" maw={440}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button mt="xs" leftSection={action.icon} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lightweight maritime illustration: a document floating on stylized waves. */
|
||||
function EmptyIllustration() {
|
||||
const primary = 'var(--mantine-color-emaPrimary-6)';
|
||||
const primaryLight = 'var(--mantine-color-emaPrimary-2)';
|
||||
const teal = 'var(--mantine-color-emaTeal-5)';
|
||||
const surface = 'var(--mantine-color-body)';
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="148"
|
||||
height="120"
|
||||
viewBox="0 0 148 120"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-hidden
|
||||
>
|
||||
<ellipse cx="74" cy="104" rx="58" ry="9" fill={primaryLight} opacity="0.45" />
|
||||
{/* document */}
|
||||
<g>
|
||||
<rect x="46" y="20" width="56" height="68" rx="8" fill={surface} stroke={primary} strokeWidth="2.5" />
|
||||
<rect x="56" y="34" width="36" height="4" rx="2" fill={primaryLight} />
|
||||
<rect x="56" y="46" width="36" height="4" rx="2" fill={primaryLight} />
|
||||
<rect x="56" y="58" width="24" height="4" rx="2" fill={primaryLight} />
|
||||
<circle cx="92" cy="74" r="9" fill={teal} opacity="0.9" />
|
||||
<path d="M88 74l3 3 5-6" stroke={surface} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
{/* waves */}
|
||||
<path d="M14 96c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5 8-5 16-5 8 5 16 5" stroke={teal} strokeWidth="2.5" strokeLinecap="round" opacity="0.8" />
|
||||
<path d="M22 106c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5 8-5 16-5" stroke={primary} strokeWidth="2.5" strokeLinecap="round" opacity="0.45" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
52
apps/portal/src/app/components/LanguageSwitcher.tsx
Normal file
52
apps/portal/src/app/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Menu, ActionIcon, Button } from '@mantine/core';
|
||||
import { IconWorld, IconCheck } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
/** `icon` renders a compact globe button; `button` shows the language label. */
|
||||
variant?: 'icon' | 'button';
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const current = i18n.language as AppLanguage;
|
||||
|
||||
const change = (lng: AppLanguage) => {
|
||||
if (lng !== current) i18n.changeLanguage(lng);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
{variant === 'icon' ? (
|
||||
<ActionIcon variant="subtle" size="lg" aria-label={t('language.label')}>
|
||||
<IconWorld size={20} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<IconWorld size={16} />}
|
||||
>
|
||||
{t(`language.${current}`)}
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t('language.label')}</Menu.Label>
|
||||
{SUPPORTED_LANGUAGES.map((lng) => (
|
||||
<Menu.Item
|
||||
key={lng}
|
||||
onClick={() => change(lng)}
|
||||
rightSection={
|
||||
current === lng ? <IconCheck size={16} /> : undefined
|
||||
}
|
||||
>
|
||||
{t(`language.${lng}`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
25
apps/portal/src/app/components/PageHeader.tsx
Normal file
25
apps/portal/src/app/components/PageHeader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Group, Stack, Text, Title } from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Right-aligned actions (e.g. a primary button). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||
<Stack gap={2}>
|
||||
<Title order={2}>{title}</Title>
|
||||
{subtitle && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user