From 726334c1555811bacb48e11d31a3b75b36c57717 Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Sun, 25 Jan 2026 21:50:19 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 65 ++++++- apps/client/src/app/(tabs)/calendar.tsx | 95 +++-------- apps/client/src/app/(tabs)/chat.tsx | 4 +- apps/client/src/components/CardBase.tsx | 113 +++++++++++++ .../src/components/DeleteEventModal.tsx | 158 ++++++------------ apps/client/src/components/EventCardBase.tsx | 122 ++++++-------- apps/client/src/components/ModalBase.tsx | 74 ++++++++ 7 files changed, 366 insertions(+), 265 deletions(-) create mode 100644 apps/client/src/components/CardBase.tsx create mode 100644 apps/client/src/components/ModalBase.tsx 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;