refactor: add CardBase and ModalBase components

- Add CardBase: reusable card with header, content, footer
  - Configurable via props: padding, border, text size, background
- Add ModalBase: modal wrapper using CardBase internally
  - Provides backdrop, click-outside-to-close, Android back button
- Refactor EventCardBase to use CardBase
- Refactor DeleteEventModal to use ModalBase
- Refactor EventOverlay (calendar.tsx) to use ModalBase
- Update CLAUDE.md with component documentation
This commit is contained in:
2026-01-25 21:50:19 +01:00
parent 2b999d9b0f
commit 726334c155
7 changed files with 366 additions and 265 deletions

View File

@@ -0,0 +1,113 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore";
type TextSize = "text-sm" | "text-base" | "text-lg" | "text-xl";
type CardBaseProps = {
title: string;
subtitle?: string;
children: ReactNode;
footer?: {
label: string;
onPress: () => void;
};
className?: string;
scrollable?: boolean;
maxContentHeight?: number;
borderWidth?: number;
headerBorderWidth?: number;
headerPadding?: number;
contentPadding?: number;
headerTextSize?: TextSize;
contentBg?: string;
};
export const CardBase = ({
title,
subtitle,
children,
footer,
className = "",
scrollable = false,
maxContentHeight,
borderWidth = 2,
headerBorderWidth,
headerPadding,
contentPadding,
headerTextSize = "text-base",
contentBg,
}: CardBaseProps) => {
const { theme } = useThemeStore();
const effectiveHeaderBorderWidth = headerBorderWidth ?? borderWidth;
const headerPaddingClass = headerPadding ? `p-${headerPadding}` : "px-3 py-2";
const contentPaddingClass = contentPadding
? `p-${contentPadding}`
: "px-3 py-2";
const contentElement = (
<View
className={contentPaddingClass}
style={{ backgroundColor: contentBg ?? theme.secondaryBg }}
>
{children}
</View>
);
return (
<View
className={`rounded-xl overflow-hidden ${className}`}
style={{ borderWidth, borderColor: theme.borderPrimary }}
>
{/* Header */}
<View
className={headerPaddingClass}
style={{
backgroundColor: theme.chatBot,
borderBottomWidth: effectiveHeaderBorderWidth,
borderBottomColor: theme.borderPrimary,
}}
>
<Text
className={`font-bold ${headerTextSize}`}
style={{ color: theme.textPrimary }}
>
{title}
</Text>
{subtitle && (
<Text style={{ color: theme.primeFg }} numberOfLines={1}>
{subtitle}
</Text>
)}
</View>
{/* Content */}
{scrollable ? (
<ScrollView style={{ maxHeight: maxContentHeight }}>
{contentElement}
</ScrollView>
) : (
contentElement
)}
{/* Footer (optional) */}
{footer && (
<Pressable
onPress={footer.onPress}
className="py-3 items-center"
style={{
borderTopWidth: 1,
borderTopColor: theme.placeholderBg,
}}
>
<Text style={{ color: theme.primeFg }} className="font-bold">
{footer.label}
</Text>
</Pressable>
)}
</View>
);
};
export default CardBase;

View File

@@ -1,6 +1,7 @@
import { Modal, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { RecurringDeleteMode } from "@calchat/shared";
import { useThemeStore } from "../stores/ThemeStore";
import { ModalBase } from "./ModalBase";
type DeleteEventModalProps = {
visible: boolean;
@@ -13,24 +14,20 @@ type DeleteEventModalProps = {
type DeleteOption = {
mode: RecurringDeleteMode;
label: string;
description: string;
};
const RECURRING_DELETE_OPTIONS: DeleteOption[] = [
{
mode: "single",
label: "Nur dieses Vorkommen",
description: "Nur der ausgewaehlte Termin wird entfernt",
},
{
mode: "future",
label: "Dieses und zukuenftige",
description: "Dieser und alle folgenden Termine werden entfernt",
label: "Dieses und zukünftige",
},
{
mode: "all",
label: "Alle Vorkommen",
description: "Die gesamte Terminserie wird geloescht",
},
];
@@ -43,120 +40,61 @@ export const DeleteEventModal = ({
}: DeleteEventModalProps) => {
const { theme } = useThemeStore();
const title = isRecurring
? "Wiederkehrenden Termin löschen"
: "Termin loeschen";
return (
<Modal
<ModalBase
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onCancel}
onClose={onCancel}
title={title}
subtitle={eventTitle}
footer={{ label: "Abbrechen", onPress: onCancel }}
>
<Pressable
className="flex-1 justify-center items-center"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={onCancel}
>
<Pressable
className="w-11/12 rounded-2xl overflow-hidden"
style={{
backgroundColor: theme.primeBg,
borderWidth: 4,
borderColor: theme.borderPrimary,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
className="px-4 py-3"
{isRecurring ? (
// Recurring event: show three options
RECURRING_DELETE_OPTIONS.map((option) => (
<Pressable
key={option.mode}
onPress={() => onConfirm(option.mode)}
className="py-3 px-4 rounded-lg mb-2"
style={{
backgroundColor: theme.chatBot,
borderBottomWidth: 3,
borderBottomColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Text
className="font-bold text-lg"
className="font-medium text-base"
style={{ color: theme.textPrimary }}
>
{isRecurring
? "Wiederkehrenden Termin loeschen"
: "Termin loeschen"}
</Text>
<Text style={{ color: theme.textSecondary }} numberOfLines={1}>
{eventTitle}
</Text>
</View>
{/* Content */}
<View className="p-4">
{isRecurring ? (
// Recurring event: show three options
RECURRING_DELETE_OPTIONS.map((option) => (
<Pressable
key={option.mode}
onPress={() => onConfirm(option.mode)}
className="py-3 px-4 rounded-lg mb-2"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Text
className="font-medium text-base"
style={{ color: theme.textPrimary }}
>
{option.label}
</Text>
<Text
className="text-sm mt-1"
style={{ color: theme.textMuted }}
>
{option.description}
</Text>
</Pressable>
))
) : (
// Non-recurring event: simple confirmation
<View>
<Text
className="text-base mb-4"
style={{ color: theme.textPrimary }}
>
Möchtest du diesen Termin wirklich löschen?
</Text>
<Pressable
onPress={() => onConfirm("all")}
className="py-3 px-4 rounded-lg"
style={{
backgroundColor: theme.rejectButton,
}}
>
<Text
className="font-medium text-base text-center"
style={{ color: theme.buttonText }}
>
Loeschen
</Text>
</Pressable>
</View>
)}
</View>
{/* Cancel button */}
<Pressable
onPress={onCancel}
className="py-3 items-center"
style={{
borderTopWidth: 1,
borderTopColor: theme.placeholderBg,
}}
>
<Text style={{ color: theme.primeFg }} className="font-bold">
Abbrechen
{option.label}
</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
))
) : (
// Non-recurring event: simple confirmation
<View>
<Text className="text-base mb-4" style={{ color: theme.textPrimary }}>
Moechtest du diesen Termin wirklich loeschen?
</Text>
<Pressable
onPress={() => onConfirm("all")}
className="py-3 px-4 rounded-lg"
style={{
backgroundColor: theme.rejectButton,
}}
>
<Text
className="font-medium text-base text-center"
style={{ color: theme.buttonText }}
>
Loeschen
</Text>
</Pressable>
</View>
)}
</ModalBase>
);
};

View File

@@ -2,6 +2,7 @@ import { View, Text } from "react-native";
import { Feather } from "@expo/vector-icons";
import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore";
import { CardBase } from "./CardBase";
type EventCardBaseProps = {
className?: string;
@@ -61,84 +62,59 @@ export const EventCardBase = ({
children,
}: EventCardBaseProps) => {
const { theme } = useThemeStore();
return (
<View
className={`rounded-xl overflow-hidden ${className}`}
style={{ borderWidth: 2, borderColor: theme.borderPrimary }}
>
{/* Header with title */}
<View
className="px-3 py-2"
style={{
backgroundColor: theme.chatBot,
borderBottomWidth: 2,
borderBottomColor: theme.borderPrimary,
}}
>
<Text
className="font-bold text-base"
style={{ color: theme.textPrimary }}
>
{title}
<CardBase title={title} className={className} borderWidth={2}>
{/* Date */}
<View className="flex-row items-center mb-1">
<Feather
name="calendar"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
{formatDate(startTime)}
</Text>
</View>
{/* Content */}
<View
className="px-3 py-2"
style={{ backgroundColor: theme.secondaryBg }}
>
{/* Date */}
<View className="flex-row items-center mb-1">
<Feather
name="calendar"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
{formatDate(startTime)}
</Text>
</View>
{/* Time with duration */}
<View className="flex-row items-center mb-1">
<Feather
name="clock"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)})
</Text>
</View>
{/* Recurring indicator */}
{isRecurring && (
<View className="flex-row items-center mb-1">
<Feather
name="repeat"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text>
</View>
)}
{/* Description */}
{description && (
<Text style={{ color: theme.textPrimary }} className="text-sm mt-1">
{description}
</Text>
)}
{/* Action buttons slot */}
{children}
{/* Time with duration */}
<View className="flex-row items-center mb-1">
<Feather
name="clock"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)})
</Text>
</View>
</View>
{/* Recurring indicator */}
{isRecurring && (
<View className="flex-row items-center mb-1">
<Feather
name="repeat"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text>
</View>
)}
{/* Description */}
{description && (
<Text style={{ color: theme.textPrimary }} className="text-sm mt-1">
{description}
</Text>
)}
{/* Action buttons slot */}
{children}
</CardBase>
);
};

View File

@@ -0,0 +1,74 @@
import { Modal, Pressable } from "react-native";
import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore";
import { CardBase } from "./CardBase";
type ModalBaseProps = {
visible: boolean;
onClose: () => void;
title: string;
subtitle?: string;
children: ReactNode;
footer?: {
label: string;
onPress: () => void;
};
scrollable?: boolean;
maxContentHeight?: number;
};
export const ModalBase = ({
visible,
onClose,
title,
subtitle,
children,
footer,
scrollable,
maxContentHeight,
}: ModalBaseProps) => {
const { theme } = useThemeStore();
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<Pressable
className="flex-1 justify-center items-center"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={onClose}
>
<Pressable
className="w-11/12 rounded-2xl overflow-hidden"
style={{
backgroundColor: theme.primeBg,
borderWidth: 4,
borderColor: theme.borderPrimary,
}}
onPress={(e) => e.stopPropagation()}
>
<CardBase
title={title}
subtitle={subtitle}
footer={footer}
scrollable={scrollable}
maxContentHeight={maxContentHeight}
borderWidth={0}
headerBorderWidth={3}
headerPadding={4}
contentPadding={4}
headerTextSize="text-lg"
contentBg={theme.primeBg}
>
{children}
</CardBase>
</Pressable>
</Pressable>
</Modal>
);
};
export default ModalBase;