managarten/memoro/apps/mobile/features/menus/Header.tsx
Till-JS e7f5f942f3 chore: initial commit - consolidate 4 projects into monorepo
Projects included:
- maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing)
- manacore (Expo mobile + SvelteKit web + Astro landing)
- manadeck (NestJS backend + Expo mobile + SvelteKit web)
- memoro (Expo mobile + SvelteKit web + Astro landing)

This commit preserves the current state before monorepo restructuring.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:38:24 +01:00

399 lines
14 KiB
TypeScript

import React, { useMemo } from 'react';
import { View, Pressable, Platform, ScrollView, Animated, Dimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LinearGradient } from 'expo-linear-gradient';
import { Link, useRouter, usePathname } from 'expo-router';
import * as Haptics from 'expo-haptics';
import { useTheme } from '~/features/theme/ThemeProvider';
import { useTranslation } from 'react-i18next';
// BlurView import removed
import colors from '~/tailwind.config.js';
import Icon from '~/components/atoms/Icon';
import Text from '~/components/atoms/Text';
import HeaderMenu from '~/features/menus/HeaderMenu';
import ManaCounter from '~/features/subscription/ManaCounter';
import { useSettingsStore } from '~/features/settings';
interface RightIconProps {
name: string;
onPress?: () => void;
href?: string;
customElement?: React.ReactNode;
}
interface TagItem {
id: string;
name: string;
color: string;
}
interface HeaderProps {
title?: string;
showBackButton?: boolean;
rightIcon?: string;
onRightIconPress?: () => void;
rightIconHref?: string;
rightIcons?: RightIconProps[];
className?: string;
showMenu?: boolean;
onThemeToggle?: () => void;
selectedTags?: TagItem[];
onTagRemove?: (tagId: string) => void;
isHomePage?: boolean;
scrollableTitle?: boolean;
showTitle?: boolean;
isMemoDetailPage?: boolean;
backgroundColor?: string;
}
/**
* Header-Komponente
*
* Eine wiederverwendbare Header-Komponente für alle Seiten der Anwendung.
* Unterstützt einen Titel, einen Zurück-Button und ein optionales rechtes Icon.
*
* Beispiel:
* ```tsx
* <Header title="Startseite" />
* <Header title="Einstellungen" showBackButton />
* <Header title="Profil" rightIcon="settings-outline" rightIconHref="/settings" />
* ```
*/
const Header: React.FC<HeaderProps> = ({
title,
showBackButton = false,
rightIcon,
onRightIconPress,
rightIconHref,
rightIcons,
className = '',
showMenu = false,
onThemeToggle,
selectedTags = [],
onTagRemove,
isHomePage = false,
scrollableTitle = false,
showTitle = true,
isMemoDetailPage = false,
backgroundColor,
}) => {
const { t } = useTranslation();
const { tw, isDark, themeVariant } = useTheme();
const router = useRouter();
const pathname = usePathname();
const { showManaBadge } = useSettingsStore();
const insets = useSafeAreaInsets();
// Use a fixed top padding for initial render to prevent layout shift
// iOS typically has 44-47px status bar, Android varies but usually 0-24px
const safeTopPadding = insets.top || (Platform.OS === 'ios' ? 44 : 0);
// Generate title text based on selected tags - moved up for animation
const getTitleText = () => {
if (selectedTags && selectedTags.length > 0) {
// Join tag names with ' & ' separator
return selectedTags.map((tag) => tag.name).join(' & ');
}
// Return title directly, don't fallback to 'Memoro' if title is empty string
return title === '' ? '' : (title || t('app.name', 'Memoro'));
};
// Get the actual title text that will be displayed
const actualTitleText = getTitleText();
// Track if this is the initial render to skip animation
const isInitialRender = React.useRef(true);
// Animation for title fade in/out - initialize based on showTitle prop
const titleOpacity = React.useRef(new Animated.Value(showTitle && actualTitleText ? 1 : 0)).current;
// Animate title opacity when showTitle or title text changes (skip on initial render)
React.useEffect(() => {
// Skip animation on initial render to prevent layout shift
if (isInitialRender.current) {
isInitialRender.current = false;
// Set opacity directly without animation
const initialOpacity = showTitle && actualTitleText ? 1 : 0;
titleOpacity.setValue(initialOpacity);
return;
}
const targetOpacity = showTitle && actualTitleText ? 1 : 0;
Animated.timing(titleOpacity, {
toValue: targetOpacity,
duration: 200,
useNativeDriver: true,
}).start();
}, [showTitle, actualTitleText, titleOpacity]);
const triggerHaptic = async () => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} catch (error) {
console.debug('Haptic feedback error:', error);
}
};
// Bestimme, ob das Menü-Icon angezeigt werden soll
const shouldShowMenu = useMemo(() => {
// Wenn rightIcons ein Element mit 'ellipsis-vertical' enthält, zeige das HeaderMenu nicht an
// Dies verhindert, dass sowohl das HeaderMenu als auch ein benutzerdefiniertes Menü angezeigt werden
const hasCustomMenu = rightIcons?.some(
(icon) => icon.name === 'ellipsis-vertical' || icon.customElement
);
if (hasCustomMenu) {
return false;
}
// Auf Memo-Detailseiten nicht anzeigen
if (pathname && pathname.includes('/(memo)/')) {
return false;
}
// Auf allen anderen Seiten anzeigen, wenn showMenu explizit auf true gesetzt ist
return showMenu === true;
}, [pathname, showMenu, rightIcons]);
// Determine the header background color based on the theme
// Use provided backgroundColor or default to menuBackground
const headerBackgroundColor = useMemo(
() => {
if (backgroundColor) {
return backgroundColor;
}
return isDark
? colors.theme.extend.colors.dark[themeVariant].menuBackground
: colors.theme.extend.colors[themeVariant].menuBackground;
},
[backgroundColor, isDark, themeVariant]
);
// Blur effect code removed
// Bestimme die Textfarbe basierend auf dem Theme
const textColor = useMemo(() => (isDark ? '#FFFFFF' : '#000000'), [isDark]);
// Handler für den Zurück-Button
const handleBackPress = async () => {
await triggerHaptic();
router.back();
};
// Rendere die rechten Icons
const renderRightIcons = () => {
// Container für rechte Icons und Menü
const rightContainer = (
<View className={tw('flex-row items-center')} style={{ height: '100%', alignItems: 'center' }}>
{/* Zeige den ManaCounter an, wenn wir auf der Homepage sind und showManaBadge aktiviert ist */}
{isHomePage && showManaBadge && (
<View style={{ marginRight: 8 }}>
<ManaCounter />
</View>
)}
{/* Zeige die konfigurierten Icons an */}
{rightIcons && rightIcons.length > 0 && (
<View className={tw('flex-row')}>
{rightIcons.map((icon, index) => {
// Wenn ein benutzerdefiniertes Element vorhanden ist, verwende dieses
if (icon.customElement) {
return (
<React.Fragment key={`custom-${index}`}>{icon.customElement}</React.Fragment>
);
}
// Ansonsten Standard-Icon-Element erstellen
const iconElement = (
<Pressable
key={`icon-${index}`}
className={tw('p-3')}
onPress={async () => {
await triggerHaptic();
icon.onPress?.();
}}>
<Icon
name={
// Platform-specific icon for share button
icon.name === 'share-outline' && Platform.OS === 'android'
? 'share-social-outline'
: icon.name
}
size={24}
color={textColor}
/>
</Pressable>
);
// Wenn ein href vorhanden ist, verlinke das Icon
return icon.href ? (
<Link key={`link-${index}`} href={icon.href as any} asChild>
{iconElement}
</Link>
) : (
iconElement
);
})}
</View>
)}
{/* Einzelnes Icon, wenn keine Icon-Liste vorhanden ist */}
{!rightIcons && rightIcon && (
<View>
{rightIconHref ? (
<Link href={rightIconHref as any} asChild>
<Pressable
className={tw('p-3')}
onPress={async () => {
await triggerHaptic();
onRightIconPress?.();
}}>
<Icon name={rightIcon} size={24} color={textColor} />
</Pressable>
</Link>
) : (
<Pressable
className={tw('p-3')}
onPress={async () => {
await triggerHaptic();
onRightIconPress?.();
}}>
<Icon name={rightIcon} size={24} color={textColor} />
</Pressable>
)}
</View>
)}
{/* Zeige das HeaderMenu an, wenn shouldShowMenu true ist */}
{shouldShowMenu && (
<View style={{ marginRight: 0 }}>
<HeaderMenu onThemeToggle={onThemeToggle} />
</View>
)}
</View>
);
return rightContainer;
};
// Always use default text color for title
const getTitleColor = () => {
return textColor;
};
return (
<View
className={tw(`${className}`)}
style={{
backgroundColor: headerBackgroundColor,
zIndex: Platform.OS === 'web' ? 1000 : undefined,
borderBottomWidth: 0,
elevation: 0,
shadowOpacity: 0,
paddingTop: safeTopPadding,
}}>
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: headerBackgroundColor,
}}
/>
<View
style={Platform.OS === 'web' ? {
maxWidth: 640, // Match WebContainer's default width
width: '100%',
marginHorizontal: 'auto',
paddingHorizontal: Dimensions.get('window').width > 1024 ? 24 : 0, // Match WebContainer's padding
} : {}}>
<View
className={tw('flex-row items-center justify-between')}
style={{ height: 60 }}>
<View className={tw('flex-row items-center')} style={{ flex: 1, minWidth: 0, paddingLeft: showBackButton ? 8 : 18, height: '100%', alignItems: 'center' }}>
{showBackButton && (
<Pressable className={tw('ml-0 mr-6 p-3')} onPress={handleBackPress}>
<Icon name="chevron-back" size={Platform.OS === 'ios' ? 20 : 24} color={textColor} />
</Pressable>
)}
<View style={{ flexShrink: 1, marginRight: scrollableTitle ? 0 : 8, flex: 1, position: 'relative', justifyContent: 'center', height: '100%' }}>
<Animated.View style={{ opacity: titleOpacity }}>
{scrollableTitle ? (
<View style={{ position: 'relative' }}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingRight: 60 }}
>
<Pressable
onPress={async () => {
// If there are selected tags, allow removing them by clicking on the title
if (selectedTags && selectedTags.length > 0 && onTagRemove) {
await triggerHaptic();
// Remove all tags one by one
selectedTags.forEach((tag) => onTagRemove(tag.id));
}
}}>
<Text
variant={isMemoDetailPage ? "h3" : "h2"}
className={tw('font-bold')}
style={{ color: getTitleColor() }}>
{actualTitleText}
</Text>
</Pressable>
</ScrollView>
{/* Gradient overlay for scrollable title */}
<LinearGradient
colors={[
headerBackgroundColor + '00', // 0% opacity (transparent)
headerBackgroundColor // 100% opacity
]}
locations={[0, 0.8]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: 80,
pointerEvents: 'none',
zIndex: 10,
}}
/>
</View>
) : (
<Pressable
onPress={async () => {
// If there are selected tags, allow removing them by clicking on the title
if (selectedTags && selectedTags.length > 0 && onTagRemove) {
await triggerHaptic();
// Remove all tags one by one
selectedTags.forEach((tag) => onTagRemove(tag.id));
}
}}>
<Text
variant={isMemoDetailPage ? "h3" : "h2"}
className={tw('font-bold')}
numberOfLines={1}
ellipsizeMode="tail"
style={{ color: getTitleColor() }}>
{actualTitleText}
</Text>
</Pressable>
)}
</Animated.View>
</View>
</View>
{renderRightIcons()}
</View>
</View>
</View>
);
};
export default Header as React.FC<HeaderProps>;