frontend scalfolding

This commit is contained in:
Mengisteab
2026-06-10 07:03:50 +00:00
parent 4faf851a8f
commit 86edf2b971
40 changed files with 3525 additions and 79 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}