From f12d13f16c32991f98dc8df7c70220711fc3e92e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 27 Sep 2025 03:59:37 +0200 Subject: [PATCH] Style app --- .../Http/Controllers/API/TripController.php | 90 +++ backend/app/Models/Trip.php | 27 + .../2025_09_27_004838_create_trips_table.php | 32 + backend/routes/api.php | 4 + frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/App.css | 734 +++++++++++++++++- frontend/src/components/BaseModal.jsx | 48 ++ frontend/src/components/ConfirmDialog.jsx | 60 ++ frontend/src/components/Dashboard.jsx | 169 +++- frontend/src/components/TripCard.jsx | 118 +++ frontend/src/components/TripList.jsx | 54 ++ frontend/src/components/TripModal.jsx | 175 +++++ 13 files changed, 1465 insertions(+), 57 deletions(-) create mode 100644 backend/app/Http/Controllers/API/TripController.php create mode 100644 backend/app/Models/Trip.php create mode 100644 backend/database/migrations/2025_09_27_004838_create_trips_table.php create mode 100644 frontend/src/components/BaseModal.jsx create mode 100644 frontend/src/components/ConfirmDialog.jsx create mode 100644 frontend/src/components/TripCard.jsx create mode 100644 frontend/src/components/TripList.jsx create mode 100644 frontend/src/components/TripModal.jsx diff --git a/backend/app/Http/Controllers/API/TripController.php b/backend/app/Http/Controllers/API/TripController.php new file mode 100644 index 0000000..3dab4c3 --- /dev/null +++ b/backend/app/Http/Controllers/API/TripController.php @@ -0,0 +1,90 @@ +user()->id) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json($trips); + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + $validated['created_by_user_id'] = $request->user()->id; + + $trip = Trip::create($validated); + + return response()->json($trip, 201); + } + + /** + * Display the specified resource. + */ + public function show(Request $request, string $id): JsonResponse + { + $trip = Trip::where('id', $id) + ->where('created_by_user_id', $request->user()->id) + ->firstOrFail(); + + return response()->json($trip); + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, string $id): JsonResponse + { + $trip = Trip::where('id', $id) + ->where('created_by_user_id', $request->user()->id) + ->firstOrFail(); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + $trip->update($validated); + + return response()->json($trip); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Request $request, string $id): JsonResponse + { + $trip = Trip::where('id', $id) + ->where('created_by_user_id', $request->user()->id) + ->firstOrFail(); + + $trip->delete(); + + return response()->json(['message' => 'Trip deleted successfully']); + } +} diff --git a/backend/app/Models/Trip.php b/backend/app/Models/Trip.php new file mode 100644 index 0000000..296eb79 --- /dev/null +++ b/backend/app/Models/Trip.php @@ -0,0 +1,27 @@ + 'date', + 'end_date' => 'date', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } +} diff --git a/backend/database/migrations/2025_09_27_004838_create_trips_table.php b/backend/database/migrations/2025_09_27_004838_create_trips_table.php new file mode 100644 index 0000000..8cb2d53 --- /dev/null +++ b/backend/database/migrations/2025_09_27_004838_create_trips_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->foreignId('created_by_user_id')->constrained('users')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('trips'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 946570d..e387c42 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ =21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index e82bc18..f549e6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.2.0", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" diff --git a/frontend/src/App.css b/frontend/src/App.css index 1532ab9..1321eb6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,47 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap'); + +:root { + /* Color Palette from Coolors.co */ + --color-navy: #03071e; + --color-dark-red: #370617; + --color-burgundy: #6a040f; + --color-crimson: #9d0208; + --color-red: #d00000; + --color-red-orange: #dc2f02; + --color-orange: #e85d04; + --color-orange-yellow: #f48c06; + --color-yellow-orange: #faa307; + --color-yellow: #ffba08; + + /* Semantic colors */ + --primary-color: var(--color-orange); + --primary-hover: var(--color-red-orange); + --secondary-color: var(--color-burgundy); + --accent-color: var(--color-yellow-orange); + --danger-color: var(--color-crimson); + --danger-hover: var(--color-red); + --text-primary: var(--color-navy); + --text-secondary: var(--color-dark-red); + --text-muted: #666; + + /* Background colors */ + --bg-primary: #faf8f5; + --bg-secondary: #f5f1eb; + --bg-light: #fdf9f4; + --bg-card: #fbf7f2; + --bg-gradient: linear-gradient(135deg, #faf8f5 0%, #f5f1eb 100%); + + /* Typography */ + --font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + --font-secondary: 'Playfair Display', Georgia, 'Times New Roman', serif; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; +} + * { box-sizing: border-box; } @@ -5,14 +49,18 @@ body { margin: 0; padding: 0; + background: var(--bg-gradient); + min-height: 100vh; + font-family: var(--font-primary); + line-height: 1.6; } .App { min-height: 100vh; display: flex; - align-items: center; + align-items: flex-start; justify-content: center; - padding: 2rem; + padding: 0; width: 100vw; box-sizing: border-box; } @@ -26,7 +74,7 @@ body { .login-form { background: #f8f9fa; padding: 2rem; - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); width: 100%; max-width: 400px; @@ -47,23 +95,29 @@ body { .form-group label { display: block; margin-bottom: 0.5rem; - font-weight: 500; - color: #555; + font-weight: 600; + color: var(--color-dark-red); + background: rgba(255, 244, 230, 0.3); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + display: inline-block; } .form-group input { width: 100%; padding: 0.75rem; - border: 1px solid #ddd; - border-radius: 4px; + border: 1px solid var(--primary-color); + border-radius: var(--radius-md); font-size: 1rem; box-sizing: border-box; + background: #fff4e6; + color: #000; } .form-group input:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); + border-color: var(--color-red-orange); + box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25); } .form-group input.error { @@ -80,7 +134,7 @@ body { .alert { padding: 0.75rem 1rem; margin-bottom: 1rem; - border-radius: 4px; + border-radius: var(--radius-sm); } .alert-success { @@ -98,21 +152,21 @@ body { button[type="submit"] { width: 100%; padding: 0.75rem; - background-color: #007bff; + background-color: var(--primary-color); color: white; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); font-size: 1rem; cursor: pointer; transition: background-color 0.2s; } button[type="submit"]:hover:not(:disabled) { - background-color: #0056b3; + background-color: var(--primary-hover); } button[type="submit"]:disabled { - background-color: #6c757d; + background-color: var(--text-muted); cursor: not-allowed; } @@ -136,7 +190,7 @@ button[type="submit"]:disabled { .auth-toggle { display: flex; margin-bottom: 2rem; - border-radius: 8px; + border-radius: var(--radius-md); overflow: hidden; border: 1px solid #ddd; } @@ -151,7 +205,7 @@ button[type="submit"]:disabled { } .auth-toggle button.active { - background: #007bff; + background: var(--primary-color); color: white; } @@ -167,7 +221,7 @@ button[type="submit"]:disabled { .link-button { background: none; border: none; - color: #007bff; + color: var(--primary-color); cursor: pointer; text-decoration: underline; padding: 0; @@ -175,14 +229,29 @@ button[type="submit"]:disabled { } .link-button:hover { - color: #0056b3; + color: var(--primary-hover); } /* Dashboard Styles */ .dashboard { + width: 1200px; max-width: 1200px; margin: 0 auto; padding: 2rem; + position: relative; +} + +.dashboard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 200px; + background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 50%, var(--color-red-orange) 100%); + opacity: 0.03; + border-radius: 0 0 50px 50px; + z-index: -1; } .dashboard-header { @@ -194,9 +263,74 @@ button[type="submit"]:disabled { border-bottom: 1px solid #eee; } +.dashboard-title { + display: flex; + align-items: center; +} + .dashboard-header h1 { margin: 0; - color: #333; + color: var(--text-primary); + background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 25%, var(--color-red-orange) 50%, var(--color-crimson) 75%, var(--color-dark-red) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 700; + font-size: 2rem; + font-family: var(--font-secondary); +} + +/* User menu styles */ +.user-dropdown { + position: relative; +} + +.user-menu-trigger { + background: var(--bg-card); + border: 1px solid var(--primary-color); + font-size: 0.875rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: 20px; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-primary); + font-weight: 500; +} + +.user-menu-trigger:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + color: white; + border-color: var(--primary-color); +} + +.user-menu-trigger:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.dropdown-arrow { + font-size: 0.7rem; + transition: transform 0.2s; +} + +.user-dropdown[data-open="true"] .dropdown-arrow { + transform: rotate(180deg); +} + +.user-dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid rgba(228, 93, 4, 0.2); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + min-width: 120px; + margin-top: 0.5rem; } .user-info { @@ -205,18 +339,30 @@ button[type="submit"]:disabled { gap: 1rem; } -.logout-btn { - padding: 0.5rem 1rem; - background: #dc3545; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s; +/* Trips section header */ +.trips-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; } -.logout-btn:hover { - background: #c82333; +.create-trip-btn-small { + padding: 0.5rem 1rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.875rem; + transition: all 0.2s; + margin-top: 0.2rem; +} + +.create-trip-btn-small:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + transform: translateY(-1px); } .welcome-section { @@ -239,7 +385,7 @@ button[type="submit"]:disabled { .feature-card { background: #f8f9fa; padding: 2rem; - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; } @@ -271,7 +417,7 @@ button[type="submit"]:disabled { text-align: center; padding: 3rem; background: #f8f9fa; - border-radius: 8px; + border-radius: var(--radius-md); margin: 2rem 0; } @@ -283,3 +429,529 @@ button[type="submit"]:disabled { .unauthorized-container p { color: #666; } + +/* Trip Styles */ +.trips-section { + margin-top: 4rem; + position: relative; +} + +.trips-section::before { + content: ''; + position: absolute; + top: -3.5rem; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent 0%, var(--color-yellow) 20%, var(--color-orange) 40%, var(--color-red-orange) 60%, var(--color-crimson) 80%, transparent 100%); + opacity: 0.6; +} + +.trips-section-title { + background: linear-gradient(135deg, #6a040f 0%, #370617 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 1.5rem; + font-size: 2rem; + font-weight: 700; + font-family: var(--font-secondary); +} + +.trips-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-top: 0; +} + +.trip-card { + background: var(--bg-card); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + position: relative; + border: 1px solid rgba(228, 93, 4, 0.1); + overflow: hidden; +} + +.trip-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-yellow-orange) 50%, var(--color-yellow) 100%); +} + +.trip-card:hover { + box-shadow: 0 8px 30px rgba(228, 93, 4, 0.15); + transform: translateY(-4px) scale(1.02); +} + +.add-trip-card { + background: var(--bg-card); + border: 4px dashed var(--primary-color); + border-radius: 12px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + opacity: 0.8; +} + +.add-trip-card:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + border-color: var(--primary-hover); + transform: translateY(-2px); + opacity: 1; +} + +.add-trip-card:hover .add-trip-content { + color: white; +} + +.add-trip-card:hover .add-trip-icon { + color: white; +} + +.add-trip-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: var(--primary-color); + transition: color 0.3s ease; +} + +.add-trip-icon { + width: 4rem; + height: 4rem; + color: var(--primary-color); +} + +.add-trip-text { + font-size: 1.3rem; + font-weight: 600; +} + +.trip-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.trip-card-title { + margin: 0; + color: var(--primary-color); + font-size: 1.25rem; + line-height: 1.3; + flex: 1; + margin-right: 1rem; + position: relative; +} + +.trip-card-title::before { + content: '🗺️'; + font-size: 0.9rem; + margin-right: 0.5rem; + opacity: 0.7; +} + +.trip-card-menu { + position: relative; +} + +.trip-menu-trigger { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + color: #666; + border-radius: var(--radius-sm); + transition: background-color 0.2s; +} + +.trip-menu-trigger:hover { + background: #e9ecef; +} + +.trip-dropdown { + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ddd; + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + min-width: 120px; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.5rem 1rem; + border: none; + background: none; + text-align: left; + cursor: pointer; + transition: background-color 0.2s; + color: #333; +} + +.dropdown-item:hover { + background: #f8f9fa; +} + +.dropdown-item-danger { + color: var(--danger-color); +} + +.dropdown-item-danger:hover { + background: #f8d7da; + color: var(--danger-hover); +} + +.trip-card-description { + color: #666; + margin: 0 0 1rem 0; + line-height: 1.5; +} + +.trip-card-dates { + margin-bottom: 1rem; +} + +.trip-date-range { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.trip-dates { + font-weight: 500; + color: var(--text-primary); +} + +.trip-duration { + font-size: 0.875rem; + color: #666; +} + +.trip-dates-placeholder { + color: #999; + font-style: italic; +} + +.trip-card-footer { + border-top: 1px solid #e9ecef; + padding-top: 0.75rem; + margin-top: 1rem; +} + +.trip-created { + font-size: 0.875rem; + color: #999; +} + +.empty-state { + text-align: center; + padding: 3rem 2rem; + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-light) 100%); + border-radius: 16px; + margin-top: 2rem; + border: 2px dashed rgba(228, 93, 4, 0.2); + position: relative; +} + +.empty-state::before { + content: '✈️'; + font-size: 3rem; + display: block; + margin-bottom: 1rem; + opacity: 0.6; +} + +.empty-state h3 { + color: #666; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #999; + margin: 0; +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(3, 7, 30, 0.8); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: modalOverlayFadeIn 0.2s ease-out; +} + +@keyframes modalOverlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-content { + background: var(--bg-card); + border-radius: var(--radius-md); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow: hidden; + box-shadow: + 0 25px 80px rgba(3, 7, 30, 0.4), + 0 0 0 1px rgba(228, 93, 4, 0.1); + animation: modalSlideIn 0.3s ease-out; + position: relative; +} + +.modal-content::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-red-orange) 50%, var(--color-crimson) 100%); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2rem 2rem 1rem; + background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-light) 100%); +} + +.modal-header h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; + font-weight: 700; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.modal-close { + background: var(--bg-secondary); + border: 1px solid rgba(228, 93, 4, 0.2); + font-size: 1.25rem; + cursor: pointer; + color: var(--primary-color); + padding: 0; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; + font-weight: 300; +} + +.modal-close:hover { + background: var(--primary-color); + color: white; + transform: scale(1.1); +} + +.modal-body { + padding: 0 2rem; + max-height: calc(90vh - 200px); + overflow-y: auto; +} + +.trip-form { + padding: 1.5rem 0; +} + +.trip-form .form-group { + margin-bottom: 1.5rem; +} + +.trip-form textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--primary-color); + border-radius: var(--radius-md); + font-size: 1rem; + font-family: inherit; + resize: vertical; + min-height: 80px; + box-sizing: border-box; + background: #fff4e6; + color: #000; +} + +.trip-form textarea:focus { + outline: none; + border-color: var(--color-red-orange); + box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25); +} + +.date-format-hint { + font-size: 0.875rem; + color: #666; + font-weight: normal; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding: 1.5rem; + border-top: 1px solid rgba(228, 93, 4, 0.1); + background: var(--bg-secondary); + border-radius: 0 0 16px 16px; +} + +.btn-primary { + padding: 0.75rem 1.5rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); +} + +.btn-primary:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.btn-secondary { + padding: 0.75rem 1.5rem; + background: transparent; + color: var(--secondary-color); + border: 2px solid var(--secondary-color); + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.btn-secondary:hover:not(:disabled) { + background: linear-gradient(135deg, var(--secondary-color) 0%, var(--color-burgundy) 100%); + border-color: var(--color-burgundy); + color: white; + transform: translateY(-1px); +} + +.btn-secondary:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.btn-danger { + padding: 0.75rem 1.5rem; + background: var(--danger-color); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-hover); +} + +.btn-danger:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.confirm-dialog { + max-width: 400px; +} + +.confirm-dialog-body { + padding: 1.5rem; +} + +.confirm-dialog-body p { + margin: 0; + color: var(--text-primary); + line-height: 1.5; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .dashboard { + padding: 1rem; + } + + .dashboard-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .dashboard-header-actions { + justify-content: space-between; + } + + .trips-grid { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .modal-content { + margin: 1rem; + max-width: none; + } + + .modal-actions { + flex-direction: column; + } +} diff --git a/frontend/src/components/BaseModal.jsx b/frontend/src/components/BaseModal.jsx new file mode 100644 index 0000000..589efe4 --- /dev/null +++ b/frontend/src/components/BaseModal.jsx @@ -0,0 +1,48 @@ +const BaseModal = ({ + isOpen, + onClose, + title, + children, + actions, + maxWidth = "500px", + className = "" +}) => { + if (!isOpen) return null; + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
e.stopPropagation()} + > + {title && ( +
+

{title}

+ +
+ )} + +
+ {children} +
+ + {actions && ( +
+ {actions} +
+ )} +
+
+ ); +}; + +export default BaseModal; \ No newline at end of file diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..7488504 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.jsx @@ -0,0 +1,60 @@ +import BaseModal from './BaseModal'; + +const ConfirmDialog = ({ + isOpen, + onClose, + onConfirm, + title = "Confirm Action", + message = "Are you sure you want to proceed?", + confirmText = "Confirm", + cancelText = "Cancel", + isLoading = false, + variant = "danger" // "danger" or "primary" +}) => { + const handleConfirm = async () => { + try { + await onConfirm(); + onClose(); + } catch (error) { + console.error('Confirm action failed:', error); + } + }; + + const actions = ( + <> + + + + ); + + return ( + +
+

{message}

+
+
+ ); +}; + +export default ConfirmDialog; \ No newline at end of file diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 415914a..c551cca 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -1,47 +1,164 @@ +import { useState, useEffect, useRef } from 'react'; import { useAuth } from '../contexts/AuthContext'; +import api from '../utils/api'; +import TripList from './TripList'; +import TripModal from './TripModal'; +import ConfirmDialog from './ConfirmDialog'; const Dashboard = () => { const { user, logout } = useAuth(); + const [trips, setTrips] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showTripModal, setShowTripModal] = useState(false); + const [selectedTrip, setSelectedTrip] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [tripToDelete, setTripToDelete] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showUserDropdown, setShowUserDropdown] = useState(false); + const userDropdownRef = useRef(null); + + useEffect(() => { + fetchTrips(); + }, []); + + useEffect(() => { + const handleClickOutside = (event) => { + if (userDropdownRef.current && !userDropdownRef.current.contains(event.target)) { + setShowUserDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const fetchTrips = async () => { + try { + setIsLoading(true); + const response = await api.get('/trips'); + setTrips(response.data); + } catch (error) { + console.error('Error fetching trips:', error); + } finally { + setIsLoading(false); + } + }; const handleLogout = async () => { await logout(); }; + const handleCreateTrip = () => { + setSelectedTrip(null); + setShowTripModal(true); + }; + + const handleEditTrip = (trip) => { + setSelectedTrip(trip); + setShowTripModal(true); + }; + + const handleDeleteTrip = (trip) => { + setTripToDelete(trip); + setShowDeleteConfirm(true); + }; + + const handleTripSubmit = async (tripData) => { + setIsSubmitting(true); + try { + if (selectedTrip) { + const response = await api.put(`/trips/${selectedTrip.id}`, tripData); + setTrips(trips.map(trip => + trip.id === selectedTrip.id ? response.data : trip + )); + } else { + const response = await api.post('/trips', tripData); + setTrips([response.data, ...trips]); + } + setShowTripModal(false); + setSelectedTrip(null); + } catch (error) { + console.error('Error saving trip:', error); + throw error; + } finally { + setIsSubmitting(false); + } + }; + + const confirmDeleteTrip = async () => { + try { + await api.delete(`/trips/${tripToDelete.id}`); + setTrips(trips.filter(trip => trip.id !== tripToDelete.id)); + setShowDeleteConfirm(false); + setTripToDelete(null); + } catch (error) { + console.error('Error deleting trip:', error); + throw error; + } + }; + return (
-

Trip Planner Dashboard

+
+

TripPlanner

+
- Welcome, {user?.name}! - + Welcome back! +
+ + {showUserDropdown && ( +
+ +
+ )} +
-
-

Welcome to Your Trip Planner

-

Start planning your next adventure!

-
- -
-
-

Plan Trips

-

Create and organize your travel itineraries

-
- -
-

Save Destinations

-

Keep track of places you want to visit

-
- -
-

Share Plans

-

Collaborate with friends and family

-
-
+
+ + { + setShowTripModal(false); + setSelectedTrip(null); + }} + onSubmit={handleTripSubmit} + trip={selectedTrip} + isLoading={isSubmitting} + /> + + { + setShowDeleteConfirm(false); + setTripToDelete(null); + }} + onConfirm={confirmDeleteTrip} + title="Delete Trip" + message={`Are you sure you want to delete "${tripToDelete?.name}"? This action cannot be undone.`} + confirmText="Delete" + variant="danger" + />
); }; diff --git a/frontend/src/components/TripCard.jsx b/frontend/src/components/TripCard.jsx new file mode 100644 index 0000000..d77e8fd --- /dev/null +++ b/frontend/src/components/TripCard.jsx @@ -0,0 +1,118 @@ +import { useState, useRef, useEffect } from 'react'; + +const TripCard = ({ trip, onEdit, onDelete }) => { + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const formatDate = (dateString) => { + if (!dateString) return null; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + }; + + const handleEdit = () => { + setShowDropdown(false); + onEdit(trip); + }; + + const handleDelete = () => { + setShowDropdown(false); + onDelete(trip); + }; + + const getDuration = () => { + if (!trip.start_date || !trip.end_date) return null; + + const start = new Date(trip.start_date); + const end = new Date(trip.end_date); + const diffTime = Math.abs(end - start); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + + return diffDays === 1 ? '1 day' : `${diffDays} days`; + }; + + return ( +
+
+

{trip.name}

+
+ + {showDropdown && ( +
+ + +
+ )} +
+
+ + {trip.description && ( +

{trip.description}

+ )} + +
+ {trip.start_date && trip.end_date ? ( +
+ + {formatDate(trip.start_date)} - {formatDate(trip.end_date)} + + + {getDuration()} + +
+ ) : trip.start_date ? ( + + Starts: {formatDate(trip.start_date)} + + ) : trip.end_date ? ( + + Ends: {formatDate(trip.end_date)} + + ) : ( + + Dates not set + + )} +
+ +
+ + Created {new Date(trip.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} + +
+
+ ); +}; + +export default TripCard; \ No newline at end of file diff --git a/frontend/src/components/TripList.jsx b/frontend/src/components/TripList.jsx new file mode 100644 index 0000000..b75df17 --- /dev/null +++ b/frontend/src/components/TripList.jsx @@ -0,0 +1,54 @@ +import TripCard from './TripCard'; +import { GlobeEuropeAfricaIcon } from '@heroicons/react/24/outline'; + +const TripList = ({ trips, isLoading, onEdit, onDelete, onCreateTrip }) => { + if (isLoading) { + return ( +
+
+ Loading trips... +
+
+ ); + } + + if (!trips || trips.length === 0) { + return ( +
+

Your Trips

+
+
+
+ + Create New Trip +
+
+
+
+ ); + } + + return ( +
+

Your Trips

+
+ {trips.map((trip) => ( + + ))} +
+
+ + Create New Trip +
+
+
+
+ ); +}; + +export default TripList; \ No newline at end of file diff --git a/frontend/src/components/TripModal.jsx b/frontend/src/components/TripModal.jsx new file mode 100644 index 0000000..541bf36 --- /dev/null +++ b/frontend/src/components/TripModal.jsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import BaseModal from './BaseModal'; + +const TripModal = ({ isOpen, onClose, onSubmit, trip = null, isLoading = false }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + const [errors, setErrors] = useState({}); + + useEffect(() => { + if (trip) { + setFormData({ + name: trip.name || '', + description: trip.description || '', + start_date: trip.start_date ? trip.start_date.split('T')[0] : '', + end_date: trip.end_date ? trip.end_date.split('T')[0] : '' + }); + } else { + setFormData({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + } + setErrors({}); + }, [trip, isOpen]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + + if (errors[name]) { + setErrors(prev => ({ + ...prev, + [name]: '' + })); + } + }; + + const validateForm = () => { + const newErrors = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Trip name is required'; + } + + if (formData.start_date && formData.end_date && formData.start_date > formData.end_date) { + newErrors.end_date = 'End date must be after start date'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + await onSubmit(formData); + onClose(); + } catch (error) { + if (error.response?.data?.errors) { + setErrors(error.response.data.errors); + } + } + }; + + const handleClose = () => { + setFormData({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + setErrors({}); + onClose(); + }; + + const actions = ( + <> + + + + ); + + return ( + +
+
+ + + {errors.name && {errors.name}} +
+ +
+ +