diff --git a/CLAUDE.md b/CLAUDE.md
index b05c16f..79466d8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -86,13 +86,15 @@ src/
│ ├── BaseButton.tsx # Reusable button component (themed, supports children)
│ ├── Header.tsx # Header component (themed)
│ ├── AuthButton.tsx # Reusable button for auth screens (themed, with shadow)
+│ ├── CardBase.tsx # Reusable card component (header + content + optional footer)
+│ ├── ModalBase.tsx # Reusable modal with backdrop (uses CardBase, click-outside-to-close)
│ ├── ChatBubble.tsx # Reusable chat bubble component (used by ChatMessage & TypingIndicator)
│ ├── TypingIndicator.tsx # Animated typing indicator (. .. ...) shown while waiting for AI response
-│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
+│ ├── EventCardBase.tsx # Event card layout with icons (uses CardBase)
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
│ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons)
-│ └── DeleteEventModal.tsx # Unified delete confirmation modal (recurring: 3 options, non-recurring: simple confirm)
+│ └── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase)
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
├── logging/
│ ├── index.ts # Re-exports
@@ -157,6 +159,58 @@ setTheme("defaultDark"); // or "defaultLight"
**Note:** `shadowColor` only works on iOS. Android uses `elevation` with system-defined shadow colors.
+### Base Components (CardBase & ModalBase)
+
+Reusable base components for cards and modals with consistent styling.
+
+**CardBase** - Card structure with header, content, and optional footer:
+```typescript
+ {} }}
+ // Styling props (all optional):
+ headerPadding={4} // p-{n}, default: px-3 py-2
+ contentPadding={4} // p-{n}, default: px-3 py-2
+ headerTextSize="text-lg" // "text-sm" | "text-base" | "text-lg" | "text-xl"
+ borderWidth={2} // outer border, default: 2
+ headerBorderWidth={3} // header bottom border, default: borderWidth
+ contentBg={theme.primeBg} // content background color, default: theme.secondaryBg
+ scrollable={true} // wrap content in ScrollView
+ maxContentHeight={400} // for scrollable content
+>
+ {children}
+
+```
+
+**ModalBase** - Modal with backdrop using CardBase internally:
+```typescript
+ setVisible(false)}
+ title="Modal Title"
+ subtitle="Optional"
+ footer={{ label: "Close", onPress: onClose }}
+ scrollable={true}
+ maxContentHeight={400}
+>
+ {children}
+
+```
+
+ModalBase provides: transparent Modal + backdrop (click-outside-to-close) + Android back button support.
+
+**Component Hierarchy:**
+```
+CardBase
+├── ModalBase (uses CardBase)
+│ ├── DeleteEventModal
+│ └── EventOverlay (in calendar.tsx)
+└── EventCardBase (uses CardBase)
+ ├── EventCard
+ └── ProposedEventCard
+```
+
### Backend Architecture (apps/server)
```
@@ -461,10 +515,13 @@ NODE_ENV=development # development = pretty logs, production = JSON
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination and recurring delete support
-- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
+- `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props
+- `ModalBase`: Reusable modal wrapper with backdrop, uses CardBase internally - provides click-outside-to-close and Android back button support
+- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions with deleteMode display)
-- `DeleteEventModal`: Unified delete confirmation modal - shows three options for recurring events (single/future/all), simple confirm for non-recurring
+- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
+- `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches
diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx
index c50583a..4a08ab1 100644
--- a/apps/client/src/app/(tabs)/calendar.tsx
+++ b/apps/client/src/app/(tabs)/calendar.tsx
@@ -1,11 +1,4 @@
-import {
- Animated,
- Modal,
- Pressable,
- Text,
- View,
- ScrollView,
-} from "react-native";
+import { Animated, Modal, Pressable, Text, View } from "react-native";
import {
DAYS,
MONTHS,
@@ -16,6 +9,7 @@ import {
import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal";
+import { ModalBase } from "../../components/ModalBase";
import React, {
useCallback,
useEffect,
@@ -263,7 +257,6 @@ const EventOverlay = ({
onEditEvent,
onDeleteEvent,
}: EventOverlayProps) => {
- const { theme } = useThemeStore();
if (!date) return null;
const dateString = date.toLocaleDateString("de-DE", {
@@ -273,75 +266,27 @@ const EventOverlay = ({
year: "numeric",
});
+ const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`;
+
return (
-
-
- e.stopPropagation()}
- >
- {/* Header */}
-
-
- {dateString}
-
-
- {events.length} {events.length === 1 ? "Termin" : "Termine"}
-
-
-
- {/* Events List */}
-
- {events.map((event, index) => (
- onEditEvent(event)}
- onDelete={() => onDeleteEvent(event)}
- />
- ))}
-
-
- {/* Close button */}
-
-
- Schließen
-
-
-
-
-
+ {events.map((event, index) => (
+ onEditEvent(event)}
+ onDelete={() => onDeleteEvent(event)}
+ />
+ ))}
+
);
};
diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx
index f1b1c03..fc2f5f9 100644
--- a/apps/client/src/app/(tabs)/chat.tsx
+++ b/apps/client/src/app/(tabs)/chat.tsx
@@ -114,9 +114,7 @@ const Chat = () => {
const message = messages.find((m) => m.id === messageId);
if (message?.proposedChanges) {
const updatedProposals = message.proposedChanges.map((p) =>
- p.id === proposalId
- ? { ...p, respondedAction: action }
- : p,
+ p.id === proposalId ? { ...p, respondedAction: action } : p,
);
updateMessage(messageId, { proposedChanges: updatedProposals });
}
diff --git a/apps/client/src/components/CardBase.tsx b/apps/client/src/components/CardBase.tsx
new file mode 100644
index 0000000..a4c653b
--- /dev/null
+++ b/apps/client/src/components/CardBase.tsx
@@ -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 = (
+
+ {children}
+
+ );
+
+ return (
+
+ {/* Header */}
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {/* Content */}
+ {scrollable ? (
+
+ {contentElement}
+
+ ) : (
+ contentElement
+ )}
+
+ {/* Footer (optional) */}
+ {footer && (
+
+
+ {footer.label}
+
+
+ )}
+
+ );
+};
+
+export default CardBase;
diff --git a/apps/client/src/components/DeleteEventModal.tsx b/apps/client/src/components/DeleteEventModal.tsx
index d808c3b..acdb28c 100644
--- a/apps/client/src/components/DeleteEventModal.tsx
+++ b/apps/client/src/components/DeleteEventModal.tsx
@@ -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 (
-
-
- e.stopPropagation()}
- >
- {/* Header */}
- (
+ 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,
}}
>
- {isRecurring
- ? "Wiederkehrenden Termin loeschen"
- : "Termin loeschen"}
-
-
- {eventTitle}
-
-
-
- {/* Content */}
-
- {isRecurring ? (
- // Recurring event: show three options
- RECURRING_DELETE_OPTIONS.map((option) => (
- onConfirm(option.mode)}
- className="py-3 px-4 rounded-lg mb-2"
- style={{
- backgroundColor: theme.secondaryBg,
- borderWidth: 1,
- borderColor: theme.borderPrimary,
- }}
- >
-
- {option.label}
-
-
- {option.description}
-
-
- ))
- ) : (
- // Non-recurring event: simple confirmation
-
-
- Möchtest du diesen Termin wirklich löschen?
-
- onConfirm("all")}
- className="py-3 px-4 rounded-lg"
- style={{
- backgroundColor: theme.rejectButton,
- }}
- >
-
- Loeschen
-
-
-
- )}
-
-
- {/* Cancel button */}
-
-
- Abbrechen
+ {option.label}
-
-
-
+ ))
+ ) : (
+ // Non-recurring event: simple confirmation
+
+
+ Moechtest du diesen Termin wirklich loeschen?
+
+ onConfirm("all")}
+ className="py-3 px-4 rounded-lg"
+ style={{
+ backgroundColor: theme.rejectButton,
+ }}
+ >
+
+ Loeschen
+
+
+
+ )}
+
);
};
diff --git a/apps/client/src/components/EventCardBase.tsx b/apps/client/src/components/EventCardBase.tsx
index c88d69b..8810784 100644
--- a/apps/client/src/components/EventCardBase.tsx
+++ b/apps/client/src/components/EventCardBase.tsx
@@ -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 (
-
- {/* Header with title */}
-
-
- {title}
+
+ {/* Date */}
+
+
+
+ {formatDate(startTime)}
- {/* Content */}
-
- {/* Date */}
-
-
-
- {formatDate(startTime)}
-
-
-
- {/* Time with duration */}
-
-
-
- {formatTime(startTime)} - {formatTime(endTime)} (
- {formatDuration(startTime, endTime)})
-
-
-
- {/* Recurring indicator */}
- {isRecurring && (
-
-
- Wiederkehrend
-
- )}
-
- {/* Description */}
- {description && (
-
- {description}
-
- )}
-
- {/* Action buttons slot */}
- {children}
+ {/* Time with duration */}
+
+
+
+ {formatTime(startTime)} - {formatTime(endTime)} (
+ {formatDuration(startTime, endTime)})
+
-
+
+ {/* Recurring indicator */}
+ {isRecurring && (
+
+
+ Wiederkehrend
+
+ )}
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Action buttons slot */}
+ {children}
+
);
};
diff --git a/apps/client/src/components/ModalBase.tsx b/apps/client/src/components/ModalBase.tsx
new file mode 100644
index 0000000..16c9a37
--- /dev/null
+++ b/apps/client/src/components/ModalBase.tsx
@@ -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 (
+
+
+ e.stopPropagation()}
+ >
+
+ {children}
+
+
+
+
+ );
+};
+
+export default ModalBase;