Refine admin PWA layout and tamagui usage

This commit is contained in:
Codex Agent
2026-01-15 22:24:10 +01:00
parent 8941860140
commit c533d43c0f
37 changed files with 51503 additions and 21989 deletions

View File

@@ -16,9 +16,11 @@ export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
const { t } = useTranslation('mobile');
const location = useLocation();
const { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
const surfaceColor = surface;
const navSurface = withAlpha(surfaceColor, 0.92);
const { surface, border, primary, accent, muted, subtle, shadow, glassSurfaceStrong, glassBorder, glassShadow } = useAdminTheme();
const surfaceColor = glassSurfaceStrong ?? surface;
const navSurface = glassSurfaceStrong ?? withAlpha(surfaceColor, 0.92);
const navBorder = glassBorder ?? border;
const navShadow = glassShadow ?? shadow;
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
const isDeepHome = active === 'home' && location.pathname !== adminPath('/mobile/dashboard');
@@ -38,14 +40,14 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
right={0}
backgroundColor={navSurface}
borderTopWidth={1}
borderColor={border}
borderColor={navBorder}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
shadowColor={navShadow}
shadowOpacity={0.12}
shadowRadius={16}
shadowOffset={{ width: 0, height: -6 }}
// allow for safe-area inset on modern phones
style={{
paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)',
@@ -58,6 +60,8 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
const activeState = item.key === active;
const isPressed = pressedKey === item.key;
const IconCmp = item.icon;
const activeBg = primary;
const activeShadow = withAlpha(primary, 0.4);
return (
<Pressable
key={item.key}
@@ -78,9 +82,10 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
backgroundColor={activeState ? accentSoft : 'transparent'}
backgroundColor={activeState ? activeBg : 'transparent'}
gap="$1"
style={{
boxShadow: activeState ? `0 10px 22px ${activeShadow}` : undefined,
transform: isPressed ? 'scale(0.96)' : 'scale(1)',
opacity: isPressed ? 0.9 : 1,
transition: 'transform 140ms ease, background-color 140ms ease, opacity 140ms ease',
@@ -93,17 +98,17 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
width={28}
height={3}
borderRadius={999}
backgroundColor={primary}
backgroundColor={accent}
/>
) : null}
<YStack width={ICON_SIZE} height={ICON_SIZE} alignItems="center" justifyContent="center" shrink={0}>
<IconCmp size={ICON_SIZE} color={activeState ? primary : subtle} />
<IconCmp size={ICON_SIZE} color={activeState ? 'white' : subtle} />
</YStack>
<Text
fontSize="$xs"
fontWeight="700"
fontFamily="$body"
color={activeState ? primary : muted}
color={activeState ? 'white' : muted}
textAlign="center"
flexShrink={1}
>

View File

@@ -37,13 +37,32 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
const {
background,
surface,
border,
text,
muted,
warningBg,
warningText,
primary,
danger,
shadow,
glassSurfaceStrong,
glassBorder,
glassShadow,
appBackground,
} = useAdminTheme();
const backgroundColor = background;
const surfaceColor = surface;
const borderColor = border;
const textColor = text;
const mutedText = muted;
const headerSurface = withAlpha(surfaceColor, 0.94);
const headerSurface = glassSurfaceStrong ?? withAlpha(surfaceColor, 0.94);
const headerBorder = glassBorder ?? borderColor;
const actionSurface = glassSurfaceStrong ?? surfaceColor;
const actionBorder = glassBorder ?? borderColor;
const actionShadow = glassShadow ?? shadow;
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
@@ -152,10 +171,17 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
width={34}
height={34}
borderRadius={12}
backgroundColor={surfaceColor}
backgroundColor={actionSurface}
borderWidth={1}
borderColor={actionBorder}
alignItems="center"
justifyContent="center"
position="relative"
style={{
boxShadow: `0 10px 18px ${actionShadow}`,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
@@ -190,6 +216,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
backgroundColor={primary}
alignItems="center"
justifyContent="center"
style={{ boxShadow: `0 10px 18px ${withAlpha(primary, 0.32)}` }}
>
<QrCode size={16} color="white" />
</XStack>
@@ -200,18 +227,23 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
);
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={backgroundColor}
minHeight="100vh"
alignItems="center"
style={{ background: appBackground }}
>
<YStack
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
borderColor={headerBorder}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
shadowColor={shadow}
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
shadowColor={actionShadow}
shadowOpacity={0.08}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
width="100%"
maxWidth={800}
position="sticky"

View File

@@ -2,27 +2,32 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useAdminTheme } from '../theme';
import { ADMIN_GRADIENTS, useAdminTheme } from '../theme';
import { withAlpha } from './colors';
export function MobileCard({
children,
className,
style,
...rest
}: React.ComponentProps<typeof YStack>) {
const { surface, border, shadow } = useAdminTheme();
const { surface, border, shadow, glassSurface, glassBorder, glassShadow } = useAdminTheme();
return (
<YStack
className={['admin-fade-up', className].filter(Boolean).join(' ')}
backgroundColor={surface}
borderRadius={18}
borderWidth={1}
borderColor={border}
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={14}
backgroundColor={glassSurface ?? surface}
borderRadius={20}
borderWidth={2}
borderColor={glassBorder ?? border}
shadowColor={glassShadow ?? shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
padding="$3.5"
space="$2"
style={{
...style,
}}
{...rest}
>
{children}
@@ -99,13 +104,18 @@ export function CTAButton({
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
}) {
const { primary, surface, border, text, danger } = useAdminTheme();
const { primary, surface, border, text, danger, glassSurfaceStrong } = useAdminTheme();
const isPrimary = tone === 'primary';
const isDanger = tone === 'danger';
const isDisabled = disabled || loading;
const backgroundColor = isDanger ? danger : isPrimary ? primary : surface;
const backgroundColor = isDanger ? danger : isPrimary ? primary : glassSurfaceStrong ?? surface;
const borderColor = isPrimary || isDanger ? 'transparent' : border;
const labelColor = isPrimary || isDanger ? 'white' : text;
const primaryStyle = isPrimary
? {
boxShadow: `0 18px 28px ${withAlpha(primary, 0.4)}`,
}
: undefined;
return (
<Pressable
onPress={isDisabled ? undefined : onPress}
@@ -119,13 +129,14 @@ export function CTAButton({
>
<XStack
height={52}
borderRadius={16}
borderRadius={18}
alignItems="center"
justifyContent="center"
backgroundColor={backgroundColor}
borderWidth={isPrimary || isDanger ? 0 : 1}
borderWidth={isPrimary || isDanger ? 0 : 2}
borderColor={borderColor}
space="$2"
style={primaryStyle}
>
{iconLeft}
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
@@ -183,6 +194,7 @@ export function ActionTile({
color,
onPress,
disabled = false,
variant = 'grid',
delayMs = 0,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
@@ -190,49 +202,56 @@ export function ActionTile({
color: string;
onPress?: () => void;
disabled?: boolean;
variant?: 'grid' | 'cluster';
delayMs?: number;
}) {
const { textStrong } = useAdminTheme();
const backgroundColor = `${color}18`;
const borderColor = `${color}40`;
const shadowColor = `${color}2b`;
const iconShadow = `${color}55`;
const { textStrong, glassSurface } = useAdminTheme();
const isCluster = variant === 'cluster';
const backgroundColor = withAlpha(color, 0.12);
const borderColor = withAlpha(color, 0.4);
const shadowColor = withAlpha(color, 0.35);
const iconShadow = withAlpha(color, 0.5);
const tileStyle = {
...(delayMs ? { animationDelay: `${delayMs}ms` } : {}),
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${color}0f)`,
boxShadow: `0 10px 24px ${shadowColor}`,
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${withAlpha(color, 0.08)})`,
boxShadow: isCluster ? `0 12px 18px ${shadowColor}` : `0 20px 30px ${shadowColor}`,
};
return (
<Pressable
onPress={disabled ? undefined : onPress}
style={{ width: '48%', marginBottom: 12, opacity: disabled ? 0.5 : 1 }}
style={{
width: isCluster ? '100%' : '48%',
flex: isCluster ? 1 : undefined,
marginBottom: isCluster ? 0 : 12,
opacity: disabled ? 0.5 : 1,
}}
disabled={disabled}
>
<YStack
className="admin-fade-up"
style={tileStyle}
borderRadius={16}
borderRadius={isCluster ? 14 : 16}
padding="$3"
space="$2.5"
backgroundColor={backgroundColor}
borderWidth={1}
backgroundColor={glassSurface ?? backgroundColor}
borderWidth={2}
borderColor={borderColor}
minHeight={110}
minHeight={120}
alignItems="center"
justifyContent="center"
>
<XStack
width={36}
height={36}
borderRadius={12}
width={44}
height={44}
borderRadius={14}
backgroundColor={color}
alignItems="center"
justifyContent="center"
style={{ boxShadow: `0 6px 14px ${iconShadow}` }}
>
<IconCmp size={16} color="white" />
<IconCmp size={18} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">
<Text fontSize="$sm" fontWeight="800" color={textStrong} textAlign="center">
{label}
</Text>
</YStack>

View File

@@ -17,11 +17,12 @@ type MobileScaffoldProps = {
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
const { t } = useTranslation('mobile');
const { background, surface, border, text, primary } = useAdminTheme();
const headerSurface = withAlpha(surface, 0.94);
const { background, surface, border, text, primary, glassSurfaceStrong, glassBorder, appBackground } = useAdminTheme();
const headerSurface = glassSurfaceStrong ?? withAlpha(surface, 0.94);
const headerBorder = glassBorder ?? border;
return (
<YStack backgroundColor={background} minHeight="100vh">
<YStack backgroundColor={background} minHeight="100vh" style={{ background: appBackground }}>
<XStack
alignItems="center"
justifyContent="space-between"
@@ -30,7 +31,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
paddingBottom="$3"
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={border}
borderColor={headerBorder}
position="sticky"
top={0}
zIndex={60}

View File

@@ -92,6 +92,11 @@ vi.mock('../../theme', () => ({
primary: '#FF5A5F',
danger: '#b91c1c',
shadow: 'rgba(0,0,0,0.12)',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));