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:
113
apps/client/src/components/CardBase.tsx
Normal file
113
apps/client/src/components/CardBase.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
74
apps/client/src/components/ModalBase.tsx
Normal file
74
apps/client/src/components/ModalBase.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user