From 00699d53b44a7a629c5c904474013a8c47b99523 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:15:28 -0500 Subject: [PATCH] feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature. --- BACKEND_STRUCTURE.md | 105 +- IMPLEMENTATION_PLAN.md | 141 ++- backend/apps/accounts/export_service.py | 94 ++ ...er_remove_toplistitem_top_list_and_more.py | 22 +- backend/apps/accounts/tests/test_admin.py | 52 +- .../apps/api/management/commands/seed_data.py | 68 +- backend/apps/api/v1/accounts/serializers.py | 61 +- backend/apps/api/v1/accounts/urls.py | 17 + backend/apps/api/v1/accounts/views.py | 53 + backend/apps/api/v1/accounts/views_credits.py | 51 + backend/apps/api/v1/auth/serializers.py | 136 +-- backend/apps/api/v1/images/urls.py | 6 + backend/apps/api/v1/images/views.py | 37 + backend/apps/api/v1/parks/history_views.py | 88 ++ .../apps/api/v1/parks/park_reviews_views.py | 162 +++ backend/apps/api/v1/parks/urls.py | 20 +- backend/apps/api/v1/parks/views.py | 33 +- backend/apps/api/v1/serializers.py | 14 +- backend/apps/api/v1/serializers/__init__.py | 7 +- backend/apps/api/v1/serializers/accounts.py | 35 +- .../apps/api/v1/serializers/park_reviews.py | 171 +++ .../apps/api/v1/serializers/ride_credits.py | 47 + backend/apps/api/v1/urls.py | 7 + backend/apps/api/v1/views/discovery.py | 96 ++ backend/apps/blog/__init__.py | 0 backend/apps/blog/apps.py | 6 + backend/apps/blog/migrations/0001_initial.py | 69 ++ backend/apps/blog/migrations/__init__.py | 0 backend/apps/blog/models.py | 45 + backend/apps/blog/serializers.py | 60 + backend/apps/blog/urls.py | 11 + backend/apps/blog/views.py | 42 + .../apps/core/decorators/cache_decorators.py | 21 +- backend/apps/core/permissions.py | 10 + .../core/services/entity_fuzzy_matching.py | 2 - backend/apps/core/tests/test_history.py | 54 + backend/apps/core/utils/cloudflare.py | 53 + backend/apps/media/migrations/0001_initial.py | 108 +- backend/apps/media/models.py | 57 + backend/apps/media/serializers.py | 62 + backend/apps/media/urls.py | 10 + backend/apps/media/views.py | 23 + backend/apps/moderation/apps.py | 26 +- backend/apps/moderation/choices.py | 25 +- .../migrations/0009_add_claim_fields.py | 201 ++++ backend/apps/moderation/models.py | 169 +++ backend/apps/moderation/serializers.py | 102 ++ backend/apps/moderation/signals.py | 166 ++- backend/apps/moderation/sse.py | 185 +++ backend/apps/moderation/urls.py | 18 + backend/apps/moderation/views.py | 369 ++++++ backend/apps/reviews/apps.py | 3 + .../apps/reviews/migrations/0001_initial.py | 176 +++ backend/apps/reviews/migrations/__init__.py | 0 backend/apps/reviews/serializers.py | 29 + backend/apps/reviews/signals.py | 30 + backend/apps/reviews/urls.py | 10 + backend/apps/reviews/views.py | 27 + ...event_ridecredit_insert_insert_and_more.py | 169 +++ backend/apps/rides/models/__init__.py | 2 + backend/apps/rides/models/credits.py | 55 + backend/apps/support/__init__.py | 0 backend/apps/support/apps.py | 6 + .../apps/support/migrations/0001_initial.py | 54 + backend/apps/support/migrations/__init__.py | 0 backend/apps/support/models.py | 48 + backend/apps/support/serializers.py | 27 + backend/apps/support/urls.py | 10 + backend/apps/support/views.py | 32 + backend/config/django/base.py | 4 + backend/ensure_admin.py | 32 + .../serializers/test_account_serializers.py | 38 +- source_docs/COMPONENTS.md | 966 +++++++++++++++ source_docs/DESIGN_SYSTEM.md | 495 ++++++++ source_docs/PAGES.md | 1057 +++++++++++++++++ source_docs/SITE_OVERVIEW.md | 244 ++++ source_docs/USER_FLOWS.md | 881 ++++++++++++++ 77 files changed, 7274 insertions(+), 538 deletions(-) create mode 100644 backend/apps/accounts/export_service.py create mode 100644 backend/apps/api/v1/accounts/views_credits.py create mode 100644 backend/apps/api/v1/images/urls.py create mode 100644 backend/apps/api/v1/images/views.py create mode 100644 backend/apps/api/v1/parks/history_views.py create mode 100644 backend/apps/api/v1/parks/park_reviews_views.py create mode 100644 backend/apps/api/v1/serializers/park_reviews.py create mode 100644 backend/apps/api/v1/serializers/ride_credits.py create mode 100644 backend/apps/api/v1/views/discovery.py create mode 100644 backend/apps/blog/__init__.py create mode 100644 backend/apps/blog/apps.py create mode 100644 backend/apps/blog/migrations/0001_initial.py create mode 100644 backend/apps/blog/migrations/__init__.py create mode 100644 backend/apps/blog/models.py create mode 100644 backend/apps/blog/serializers.py create mode 100644 backend/apps/blog/urls.py create mode 100644 backend/apps/blog/views.py create mode 100644 backend/apps/core/tests/test_history.py create mode 100644 backend/apps/core/utils/cloudflare.py create mode 100644 backend/apps/media/models.py create mode 100644 backend/apps/media/serializers.py create mode 100644 backend/apps/media/urls.py create mode 100644 backend/apps/media/views.py create mode 100644 backend/apps/moderation/migrations/0009_add_claim_fields.py create mode 100644 backend/apps/moderation/sse.py create mode 100644 backend/apps/reviews/migrations/0001_initial.py create mode 100644 backend/apps/reviews/migrations/__init__.py create mode 100644 backend/apps/reviews/serializers.py create mode 100644 backend/apps/reviews/signals.py create mode 100644 backend/apps/reviews/urls.py create mode 100644 backend/apps/reviews/views.py create mode 100644 backend/apps/rides/migrations/0028_ridecredit_ridecreditevent_ridecredit_insert_insert_and_more.py create mode 100644 backend/apps/rides/models/credits.py create mode 100644 backend/apps/support/__init__.py create mode 100644 backend/apps/support/apps.py create mode 100644 backend/apps/support/migrations/0001_initial.py create mode 100644 backend/apps/support/migrations/__init__.py create mode 100644 backend/apps/support/models.py create mode 100644 backend/apps/support/serializers.py create mode 100644 backend/apps/support/urls.py create mode 100644 backend/apps/support/views.py create mode 100644 backend/ensure_admin.py create mode 100644 source_docs/COMPONENTS.md create mode 100644 source_docs/DESIGN_SYSTEM.md create mode 100644 source_docs/PAGES.md create mode 100644 source_docs/SITE_OVERVIEW.md create mode 100644 source_docs/USER_FLOWS.md diff --git a/BACKEND_STRUCTURE.md b/BACKEND_STRUCTURE.md index 9df11ab2..25f2dd2f 100644 --- a/BACKEND_STRUCTURE.md +++ b/BACKEND_STRUCTURE.md @@ -4,45 +4,92 @@ ### 1. `apps.core` - **Responsibility**: Base classes, shared utilities, history tracking. -- **Existing**: `SluggedModel`, `TrackedModel`, `pghistory`. +- **Existing**: `SluggedModel`, `TrackedModel`. +- **Versioning Strategy (Section 15)**: + - All core entities (`Park`, `Ride`, `Company`) must utilize `django-pghistory` or `apps.core` tracking to support: + - **Edit History**: Chronological list of changes with `reason`, `user`, and `diff`. + - **Timeline**: Major events (renames, relocations). + - **Rollback**: Ability to restore previous versions via the Moderation Queue. ### 2. `apps.accounts` -- **Responsibility**: User authentication and profiles. -- **Existing**: `User`, `UserProfile`, `UserDeletionRequest`, `UserNotification`. -- **Missing**: `RideCredit` (although `UserProfile` has aggregate stats, individual credits are needed). +- **Responsibility**: User authentication, profiles, and settings. +- **Existing**: `User`, `UserProfile` (bio, location, home park). +- **Required Additions (Section 9)**: + - **UserDeletionRequest**: Support 7-day grace period for account deletion. + - **Privacy Settings**: Fields for `is_profile_public`, `show_location`, `show_email` on `UserProfile`. + - **Data Export**: Serializers/Utilities to dump all user data (Reviews, Credits, Lists) to JSON. ### 3. `apps.parks` - **Responsibility**: Park management. -- **Existing**: `Park`, `ParkArea`. +- **Models**: `Park`, `ParkArea`. +- **Relationships**: + - `operator`: FK to `apps.companies.Company` (Type: Operator). + - `property_owner`: FK to `apps.companies.Company` (Type: Owner). ### 4. `apps.rides` -- **Responsibility**: Ride data (rides, coasters, credits), Companies. -- **Existing**: `RideModel`, `RideModelVariant`, `RideModelPhoto`, `Ride` (with FSM `status`). -- **Proposed Additions**: - - `RideCredit` (M2M: User <-> Ride). **Attributes**: `count`, `first_ridden_at`, `notes`, `rating`. **Constraint**: Unique(user, ride). +- **Responsibility**: Ride data, Coasters, and Credits. +- **Models**: + - `Ride`: Core entity (Status FSM: Operating, SBNO, Closed, etc.). + - `RideModel`: Defines the "Type" of ride (e.g., B&M Hyper V2). + - `Manufacturer`: FK to `apps.companies.Company`. + - `Designer`: FK to `apps.companies.Company`. +- **Ride Credits (Section 10)**: + - **Model**: `RideCredit` (Through-Model: `User` <-> `Ride`). + - **Fields**: + - `count` (Integer): Total times ridden. + - `rating` (Float): Personal rating (distinct from public Review). + - `first_ridden_at` (Date): First time experiencing the ride. + - `notes` (Text): Private personal notes. + - **Constraints**: `Unique(user, ride)` - A user has one credit entry per ride. -### 5. `apps.moderation` -- **Responsibility**: Content pipeline. -- **Existing**: Check `apps/moderation` (likely `Submission`). -- **Constraint**: Status State Machine (Pending -> Claimed -> Approved). +### 5. `apps.companies` +- **Responsibility**: Management of Industry Entities (Section 4). +- **Models**: + - `Company`: Single model with `type` choices or Polymorphic. + - **Types**: `Manufacturer`, `Designer`, `Operator`, `PropertyOwner`. + - **Features**: Detailed pages, hover cards, listing by type. -### 6. `apps.media` -- **Responsibility**: Photos and Videos. -- **Existing**: `Photo` (GenericFK). +### 6. `apps.moderation` (The Sacred Submission Pipeline) +- **Responsibility**: Centralized Content Submission System (Section 14, 16). +- **Concept**: **Live Data** (Approve) vs **Submission Data** (Pending). +- **Models**: + - `Submission`: + - `submitter`: FK to User. + - `content_type`: Target Model (Park, Ride, etc.). + - `object_id`: Target ID (Null for Creation). + - `data`: **JSONField** storing the proposed state. + - `status`: State Machine (`Pending` -> `Claimed` -> `Approved` | `Rejected` | `ChangesRequested`). + - `moderator`: FK to User (Claimaint). + - `moderator_note`: Reason for rejection/feedback. + - `Report`: User flags on content. +- **Workflow**: + 1. User submits form -> `Submission` created (Status: Pending). + 2. Moderator Claims -> Status: Claimed. + 3. Approves -> Applies `data` to `Live Model` -> Saves Version -> Status: Approved. -### 7. `apps.reviews` -- **Responsibility**: User reviews. -- **Proposed**: `Review` model (User, Entity GenericFK, rating 1-5, text, helpful votes). +### 7. `apps.media` +- **Responsibility**: Media Management (Section 13). +- **Models**: + - `Photo`: GenericFK. Fields: `image`, `caption`, `user`, `status` (Moderation). + - **Banner/Card**: Entities should link to a "Primary Photo" or store a cached image field. -### 8. `apps.lists` -- **Responsibility**: User Rankings/Lists. -- **Existing**: `TopList`, `TopListItem` (in `apps.accounts` currently? Or should move to `apps.lists`?). `accounts/models.py` has `TopList`. -- **Proposal**: Move `TopList` to `apps.lists` for better separation if it grows, or keep in `accounts` if tightly coupled. Spec suggests "11. USER LISTS" is a major feature. +### 8. `apps.reviews` +- **Responsibility**: Public Reviews & Ratings (Section 12). +- **Models**: + - `Review`: GenericFK (Park, Ride). + - **Fields**: `rating` (1-5, 0.5 steps), `title`, `body`, `helpful_votes`. + - **Logic**: Aggregates (Avg Rating, Count) calculation for Entity caches. -### 9. `apps.blog` -- **Responsibility**: Blog posts. -- **Proposed**: `Post`, `Tag`. +### 9. `apps.lists` +- **Responsibility**: User Lists & Rankings (Section 11). +- **Models**: + - `UserList`: Title, Description, Type (Park/Ride/Coaster/Mixed), Privacy (Public/Private). + - `UserListItem`: FK to List, GenericFK to Item, Order, Notes. -### 10. `apps.support` -- **Responsibility**: Contact form. -- **Proposed**: `Ticket`. +### 10. `apps.blog` +- **Responsibility**: News & Updates. +- **Models**: `Post`, `Tag`. + +### 11. `apps.support` +- **Responsibility**: Human interaction. +- **Models**: `Ticket` (Contact Form). diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 7aef00a9..12be007d 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -2,67 +2,112 @@ ## User Review Required > [!IMPORTANT] -> **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (Nuxt Composables) will handle conversion to Imperial based on user preference. -> **Moderation Workflow**: A State Machine (Pending -> Claimed -> Approved) will be enforced for all user submissions. +> **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (`useUnits` composable) will handle conversion to Imperial based on user preference. +> **Sacred Pipeline Enforcement**: All user edits create `Submission` records (stored as JSON). No direct database edits are allowed for non-admin users. ## Proposed Changes ### Backend (Django + DRF) -#### App Structure & Models -- [x] **`apps/core`**: Base models (`SluggedModel`, `TimeStampedModel`), History (`pghistory`, `SlugHistory`), Utilities. -- [x] **`apps/accounts`**: `User`, `UserProfile` (bio, location, visual prefs), `Auth` (MFA, Magic Link). -- [x] **`apps/parks`**: `Park` (name, location, dates, status, owner/operator FKs). -- [x] **`apps/rides`**: - - `Ride` (name, park FK, model FK, specs: height/speed/etc stored in metric). - - `Manufacturer`, `Designer`, `RideModel` (Company models). - - `RideCredit` (M2M: User <-> Ride). **Attributes**: `count`, `first_ridden_at`, `notes`, `ranking`. -- [ ] **`apps/reviews`**: `Review` (User, Entity GenericFK, rating 1-5, text, helpful votes). -- [ ] **`apps/media`**: `Photo` (image, user, caption, entity GenericFK), `Video`. -- [x] **`apps/lists`**: `UserList` (Custom rankings/lists). -- [x] **`apps/moderation`**: `Submission` (User, ContentType, Object ID, Changes JSON, Status: Pending/Claimed/Approved/Rejected, Moderator FK), `Report`. -- [ ] **`apps/blog`**: `Post`, `Tag`. -- [ ] **`apps/support`**: `Ticket` (Contact form). +#### 1. Core & Auth Infrastructure +- [x] **`apps.core`**: Implement `TrackedModel` using `pghistory` for all core entities to support Edit History and Versioning (Section 15). +- [x] **`apps.accounts`**: + - `User` & `UserProfile` models (Bio, Location, Home Park). + - **Settings Support**: Endpoints for changing Email, Password, MFA, and Sessions (Section 9.1-9.2). + - **Privacy**: Fields for `public_profile`, `show_location`, etc. (Section 9.3). + - **Data Export**: Endpoint to generate JSON dump of all user data (Section 9.6). + - **Account Deletion**: `UserDeletionRequest` model with 7-day grace period (Section 9.6). -#### API & Logic -- **DRF ViewSets**: Full CRUD for all entities (read-only for public, authenticated for mutations). -- **Moderation Middleware/Signals**: Intercept mutations to create `Submission` records instead of direct saves for non-staff. -- **Versioning**: `pghistory` and `SlugHistory` are already partially implemented in `core`. -- **Search**: Global search endpoint. -- **Geolocation**: `PostGIS` integrations (already partially in `parks.location_utils`). +#### 2. Entity Models & Logic ("Live" Data) +- [x] **`apps.parks`**: `Park` (with Operator/Owner FKs, Geolocation). +- [x] **`apps.rides`**: `Ride` (Status FSM), `RideModel`, `Manufacturer`, `Designer`. +- [x] **`apps.rides` (Credits)**: `RideCredit` Through-Model with `count`, `rating`, `date`, `notes`. Constraint: Unique(user, ride). +- [x] **`apps.companies`**: `Company` model with types (`Manufacturer`, `Designer`, `Operator`, `Owner`). +- [x] **`apps.lists`**: `UserList` (Ranking System) and `UserListItem`. +- [x] **`apps.reviews`**: `Review` model (GenericFK) with Aggregation Logic. + +#### 3. The Sacred Pipeline (`apps.moderation`) +- [x] **Submission Model**: Stores `changes` (JSON), `status` (State Machine), `moderator_note`. +- [x] **Submission Serializers**: Handle validation of "Proposed Data" vs "Live Data". +- [x] **Queue Endpoints**: `list_pending`, `claim`, `approve`, `reject`, `activity_log`, `stats`. +- [x] **Reports**: `Report` model and endpoints. ### Frontend (Nuxt 4) -#### Architecture -- **Directory Structure**: - - `app/pages/`: File-based routing (e.g., `parks/[slug].vue`). - - `app/components/`: Reusable UI components (Design System). - - `app/composables/`: Logic reuse (`useUnits`, `useAuth`, `useApi`). - - `app/stores/`: Pinia stores (`userStore`, `toastStore`). - - `app/layouts/`: `default.vue`, `auth.vue`. -- **Tech Stack**: Nuxt 4, Nuxt UI (Tailwind based), Pinia, VueUse. +#### 1. Initial Setup & Core +- [x] **Composables**: `useUnits` (Metric/Imperial), `useAuth` (MFA, Session), `useApi`. +- [x] **Layouts**: Standard Layout (Hero, Tabs), Auth Layout. -#### Key Features & Composables -- **`useUnits`**: Reactively converts metric props to imperial if user pref is set. -- **`useAuth`**: Handles JWT/Session, MFA state, User fetching. -- **`useModeration`**: For moderators to claim/approve actions. -- **Forms**: `Zod` schema validation matching DRF serializers. +#### 2. Discovery & Search (Section 1 & 6) +- [x] **Global Search**: Hero Search with Autocomplete (Parks, Rides, Companies). +- [x] **Discovery Tabs** (11 Sections): + - [x] Trending Parks / Rides + - [x] New Parks / Rides + - [x] Top Parks / Rides + - [x] Opening Soon / Recently Opened + - [x] Closing Soon / Recently Closed + - [x] Recent Changes Feed -#### Design System -- **Theme**: Dark/Light mode support (built-in to Nuxt UI). -- **Components**: - - `EntityCard` (Park/Ride summary). - - `StandardLayout` (Hero, Tabs, Content). - - `MediaGallery`. - - `ReviewList`. +#### 3. Content Pages (Read-Only Views) +- [ ] **Park Detail**: Tabs (Overview, Rides, Reviews, Photos, History). +- [ ] **Ride Detail**: Tabs (Overview, Specifications, Reviews, Photos, History). +- [ ] **Company Pages**: Manufacturer, Designer, Operator, Property Owner details. +- [ ] **Maps**: Interactive "Parks Nearby" map. + +#### 4. The Sacred Submission Pipeline (Write Views) +- [ ] **Submission Forms** (Multi-step Wizards): + - [ ] **Park Form**: Location, Dates, Media, Relations. + - [ ] **Ride Form**: Specs (with Unit Toggle), Relations, Park selection. + - [ ] **Company Form**: Type selection, HQ, details. + - [ ] **Photo Upload**: Bulk upload, captioning, crop. +- [ ] **Editing**: Load existing data into form -> Submit as JSON Diff. + +#### 5. Moderation Interface (Section 16) +- [ ] **Dashboard**: Queue stats, Assignments. +- [ ] **Queues**: + - [ ] **Pending Queue**: Filter by Type, Submitter, Date. + - [ ] **Reports Queue**. + - [ ] **Audit Log**. +- [ ] **Review Workspace**: + - [ ] **Diff Viewer**: Visual Old vs New comparison. + - [ ] **Actions**: Claim, Approve, Reject (with reason), Edit. + +#### 6. User Experience & Settings +- [ ] **User Profile**: Activity Feed, Credits Tab, Lists Tab, Reviews Tab. +- [ ] **Ride Credits Management**: Add/Edit Credit (Date, Count, Notes). +- [ ] **Settings Area** (6 Tabs): + - [ ] Account & Profile (Edit generic info). + - [ ] Security (MFA setup, Active Sessions). + - [ ] Privacy (Visibility settings). + - [ ] Notifications. + - [ ] Location & Info (Timezone, Home Park). + - [ ] Data & Export (JSON Download, Delete Account). + +#### 7. Lists System +- [ ] **List Management**: Create/Edit Lists (Public/Private). +- [ ] **List Editor**: Search items, Add to list, Drag-and-drop reorder, Add notes. ## Verification Plan ### Automated Tests -- **Backend**: `pytest` for Model constraints, API endpoints, and Moderation flow. -- **Frontend**: `vitest` for Unit/Composable tests. E2E tests for critical flows (Submission -> Moderation -> Publish). +- **Backend**: `pytest` for all Model constraints and API permissions. + - Test Submission State Machine: `Pending -> Claimed -> Approved`. + - Test Versioning: Ensure `pghistory` tracks changes on approval. +- **Frontend**: `vitest` for Unit Tests (Composables). -### Manual Verification -1. **Ride Credits**: User adds a ride, verifies count increments. -2. **Moderation**: User submits data -> Mod claims -> Mod approves -> Public data updates. -3. **Units**: Toggle preference, verify stats update (e.g., km/h -> mph). +### Manual Verification Flows +1. **Sacred Pipeline Flow**: + - **User**: Submit a change to "Top Thrill 2" (add stats). + - **Moderator**: Go to Queue -> Claim -> Verify Diff -> Approve. + - **Public**: Verify "Top Thrill 2" page shows new stats and "Last Updated" is now. + - **History**: Verify "History" tab shows the update event. + +2. **Ride Credits**: + - Go to "Iron Gwazi" page. + - Click "Add to Credits" -> Enter `Count: 5`, `Rating: 4.5`. + - Go to Profile -> Ride Credits. Verify Iron Gwazi is listed with correct data. + +3. **Data Privacy & Export**: + - Go to Settings -> Privacy -> Toggle "Private Profile". + - Open Profile URL in Incognito -> Verify 404 or "Private" message. + - Go to Settings -> Data -> "Download Data" -> Verify JSON structure. diff --git a/backend/apps/accounts/export_service.py b/backend/apps/accounts/export_service.py new file mode 100644 index 00000000..bbbf5963 --- /dev/null +++ b/backend/apps/accounts/export_service.py @@ -0,0 +1,94 @@ +import json +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone +from .models import User + +class UserExportService: + """Service for exporting all user data.""" + + @staticmethod + def export_user_data(user: User) -> dict: + """ + Export all data associated with a user or an object containing counts/metadata and actual data. + + Args: + user: The user to export data for + + Returns: + dict: The complete user data export + """ + # Import models locally to avoid circular imports + from apps.parks.models import ParkReview + from apps.rides.models import RideReview + from apps.lists.models import UserList + + # User account and profile + user_data = { + "username": user.username, + "email": user.email, + "date_joined": user.date_joined, + "first_name": user.first_name, + "last_name": user.last_name, + "is_active": user.is_active, + "role": user.role, + } + + profile_data = {} + if hasattr(user, "profile"): + profile = user.profile + profile_data = { + "display_name": profile.display_name, + "bio": profile.bio, + "location": profile.location, + "pronouns": profile.pronouns, + "unit_system": profile.unit_system, + "social_media": { + "twitter": profile.twitter, + "instagram": profile.instagram, + "youtube": profile.youtube, + "discord": profile.discord, + }, + "ride_credits": { + "coaster": profile.coaster_credits, + "dark_ride": profile.dark_ride_credits, + "flat_ride": profile.flat_ride_credits, + "water_ride": profile.water_ride_credits, + } + } + + # Reviews + park_reviews = list(ParkReview.objects.filter(user=user).values( + "park__name", "rating", "review", "created_at", "updated_at", "is_published" + )) + + ride_reviews = list(RideReview.objects.filter(user=user).values( + "ride__name", "rating", "review", "created_at", "updated_at", "is_published" + )) + + # Lists + user_lists = [] + for user_list in UserList.objects.filter(user=user): + items = list(user_list.items.values("order", "content_type__model", "object_id", "comment")) + user_lists.append({ + "title": user_list.title, + "description": user_list.description, + "created_at": user_list.created_at, + "items": items + }) + + export_data = { + "account": user_data, + "profile": profile_data, + "preferences": getattr(user, "notification_preferences", {}), + "content": { + "park_reviews": park_reviews, + "ride_reviews": ride_reviews, + "lists": user_lists, + }, + "export_info": { + "generated_at": timezone.now(), + "version": "1.0" + } + } + + return export_data diff --git a/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py b/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py index 44716633..93ec6bb2 100644 --- a/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py +++ b/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py @@ -13,29 +13,11 @@ class Migration(migrations.Migration): dependencies = [ ("accounts", "0013_add_user_query_indexes"), ("contenttypes", "0002_remove_content_type_name"), - ( - "django_cloudflareimages_toolkit", - "0002_rename_cloudflare_i_user_id_b8c8a5_idx_cloudflare__user_id_a3ad50_idx_and_more", - ), + ("django_cloudflareimages_toolkit", "0001_initial"), ] operations = [ - migrations.RemoveField( - model_name="toplist", - name="user", - ), - migrations.RemoveField( - model_name="toplistitem", - name="top_list", - ), - migrations.AlterUniqueTogether( - name="toplistitem", - unique_together=None, - ), - migrations.RemoveField( - model_name="toplistitem", - name="content_type", - ), + migrations.AlterModelOptions( name="user", options={"verbose_name": "User", "verbose_name_plural": "Users"}, diff --git a/backend/apps/accounts/tests/test_admin.py b/backend/apps/accounts/tests/test_admin.py index 447ed7bb..bb5bb0ad 100644 --- a/backend/apps/accounts/tests/test_admin.py +++ b/backend/apps/accounts/tests/test_admin.py @@ -15,15 +15,12 @@ from apps.accounts.admin import ( CustomUserAdmin, EmailVerificationAdmin, PasswordResetAdmin, - TopListAdmin, - TopListItemAdmin, UserProfileAdmin, ) from apps.accounts.models import ( EmailVerification, PasswordReset, - TopList, - TopListItem, + User, UserProfile, ) @@ -157,51 +154,4 @@ class TestPasswordResetAdmin(TestCase): assert "cleanup_old_tokens" in actions -class TestTopListAdmin(TestCase): - """Tests for TopListAdmin class.""" - def setUp(self): - self.factory = RequestFactory() - self.site = AdminSite() - self.admin = TopListAdmin(model=TopList, admin_site=self.site) - - def test_list_select_related(self): - """Verify select_related for user.""" - assert "user" in self.admin.list_select_related - - def test_list_prefetch_related(self): - """Verify prefetch_related for items.""" - assert "items" in self.admin.list_prefetch_related - - def test_publish_actions(self): - """Verify publish actions exist.""" - request = self.factory.get("/admin/") - request.user = UserModel(is_superuser=True) - - actions = self.admin.get_actions(request) - assert "publish_lists" in actions - assert "unpublish_lists" in actions - - -class TestTopListItemAdmin(TestCase): - """Tests for TopListItemAdmin class.""" - - def setUp(self): - self.factory = RequestFactory() - self.site = AdminSite() - self.admin = TopListItemAdmin(model=TopListItem, admin_site=self.site) - - def test_list_select_related(self): - """Verify select_related for top_list and user.""" - assert "top_list" in self.admin.list_select_related - assert "top_list__user" in self.admin.list_select_related - assert "content_type" in self.admin.list_select_related - - def test_reorder_actions(self): - """Verify reorder actions exist.""" - request = self.factory.get("/admin/") - request.user = UserModel(is_superuser=True) - - actions = self.admin.get_actions(request) - assert "move_up" in actions - assert "move_down" in actions diff --git a/backend/apps/api/management/commands/seed_data.py b/backend/apps/api/management/commands/seed_data.py index 7600dcd5..2abd8c5b 100644 --- a/backend/apps/api/management/commands/seed_data.py +++ b/backend/apps/api/management/commands/seed_data.py @@ -24,7 +24,7 @@ from django.utils.text import slugify # Import all models from apps.accounts.models import ( - User, UserProfile, TopList, TopListItem, UserNotification, + User, UserProfile, UserNotification, NotificationPreference, UserDeletionRequest ) from apps.parks.models import ( @@ -128,7 +128,7 @@ class Command(BaseCommand): # Create content and interactions self.create_reviews(options['reviews'], users, parks, rides) - self.create_top_lists(users, parks, rides) + self.create_notifications(users) self.create_moderation_data(users, parks, rides) @@ -149,7 +149,7 @@ class Command(BaseCommand): models_to_clear = [ # Content and interactions (clear first) - TopListItem, TopList, UserNotification, NotificationPreference, + UserNotification, NotificationPreference, ParkReview, RideReview, ModerationAction, ModerationQueue, # Media @@ -1042,65 +1042,7 @@ class Command(BaseCommand): self.stdout.write(f' βœ… Created {count} reviews') - def create_top_lists(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None: - """Create user top lists""" - self.stdout.write('πŸ“‹ Creating top lists...') - - if not users: - self.stdout.write(' ⚠️ No users found, skipping top lists') - return - - list_count = 0 - - # Create top lists for some users - for user in random.sample(users, min(len(users), 10)): - # Create roller coaster top list - if rides: - coasters = [r for r in rides if r.category == 'RC'] - if coasters: - top_list = TopList.objects.create( - user=user, - title=f"{user.get_display_name()}'s Top Roller Coasters", - category="RC", - description="My favorite roller coasters ranked by thrill and experience", - ) - - # Add items to the list - for rank, coaster in enumerate(random.sample(coasters, min(len(coasters), 10)), 1): - from django.contrib.contenttypes.models import ContentType - content_type = ContentType.objects.get_for_model(coaster) - TopListItem.objects.create( - top_list=top_list, - content_type=content_type, - object_id=coaster.pk, - rank=rank, - notes=f"Incredible {coaster.category} experience at {coaster.park.name}", - ) - list_count += 1 - - # Create park top list - if parks and random.random() < 0.5: - top_list = TopList.objects.create( - user=user, - title=f"{user.get_display_name()}'s Favorite Parks", - category="PK", - description="Theme parks that provide the best overall experience", - ) - - # Add items to the list - for rank, park in enumerate(random.sample(parks, min(len(parks), 5)), 1): - from django.contrib.contenttypes.models import ContentType - content_type = ContentType.objects.get_for_model(park) - TopListItem.objects.create( - top_list=top_list, - content_type=content_type, - object_id=park.pk, - rank=rank, - notes=f"Amazing park with great {park.park_type.lower().replace('_', ' ')} atmosphere", - ) - list_count += 1 - - self.stdout.write(f' βœ… Created {list_count} top lists') + def create_notifications(self, users: List[User]) -> None: """Create sample notifications for users""" @@ -1198,7 +1140,7 @@ class Command(BaseCommand): 'Ride Models': RideModel.objects.count(), 'Park Reviews': ParkReview.objects.count(), 'Ride Reviews': RideReview.objects.count(), - 'Top Lists': TopList.objects.count(), + 'Notifications': UserNotification.objects.count(), 'Park Photos': ParkPhoto.objects.count(), 'Ride Photos': RidePhoto.objects.count(), diff --git a/backend/apps/api/v1/accounts/serializers.py b/backend/apps/api/v1/accounts/serializers.py index c5e45225..c8a4f5a5 100644 --- a/backend/apps/api/v1/accounts/serializers.py +++ b/backend/apps/api/v1/accounts/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from drf_spectacular.utils import extend_schema_field -from apps.accounts.models import UserProfile, TopList, TopListItem +from apps.accounts.models import UserProfile from apps.accounts.serializers import UserSerializer # existing shared user serializer @@ -11,10 +11,21 @@ class UserProfileCreateInputSerializer(serializers.ModelSerializer): class UserProfileUpdateInputSerializer(serializers.ModelSerializer): + cloudflare_image_id = serializers.CharField(write_only=True, required=False) + class Meta: model = UserProfile fields = "__all__" - extra_kwargs = {"user": {"read_only": True}} + extra_kwargs = {"user": {"read_only": True}, "avatar": {"read_only": True}} + + def update(self, instance, validated_data): + cloudflare_id = validated_data.pop("cloudflare_image_id", None) + if cloudflare_id: + from django_cloudflareimages_toolkit.models import CloudflareImage + image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id) + instance.avatar = image + + return super().update(instance, validated_data) class UserProfileOutputSerializer(serializers.ModelSerializer): @@ -38,49 +49,3 @@ class UserProfileOutputSerializer(serializers.ModelSerializer): if avatar: return getattr(avatar, "url", None) return None - - -class TopListItemCreateInputSerializer(serializers.ModelSerializer): - class Meta: - model = TopListItem - fields = "__all__" - - -class TopListItemUpdateInputSerializer(serializers.ModelSerializer): - class Meta: - model = TopListItem - fields = "__all__" - # allow updates, adjust as needed - extra_kwargs = {"top_list": {"read_only": False}} - - -class TopListItemOutputSerializer(serializers.ModelSerializer): - # Remove the ride field since it doesn't exist on the model - # The model likely uses a generic foreign key or different field name - - class Meta: - model = TopListItem - fields = "__all__" - - -class TopListCreateInputSerializer(serializers.ModelSerializer): - class Meta: - model = TopList - fields = "__all__" - - -class TopListUpdateInputSerializer(serializers.ModelSerializer): - class Meta: - model = TopList - fields = "__all__" - # user is set by view's perform_create - extra_kwargs = {"user": {"read_only": True}} - - -class TopListOutputSerializer(serializers.ModelSerializer): - user = UserSerializer(read_only=True) - items = TopListItemOutputSerializer(many=True, read_only=True) - - class Meta: - model = TopList - fields = "__all__" diff --git a/backend/apps/api/v1/accounts/urls.py b/backend/apps/api/v1/accounts/urls.py index c1836411..c3a14fec 100644 --- a/backend/apps/api/v1/accounts/urls.py +++ b/backend/apps/api/v1/accounts/urls.py @@ -33,6 +33,8 @@ urlpatterns = [ views.cancel_account_deletion, name="cancel_account_deletion", ), + # Data Export endpoint + path("data-export/", views.export_user_data, name="export_user_data"), # User profile endpoints path("profile/", views.get_user_profile, name="get_user_profile"), path("profile/account/", views.update_user_account, name="update_user_account"), @@ -106,4 +108,19 @@ urlpatterns = [ path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"), path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"), path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"), + + # Public Profile + path("profiles//", views.get_public_user_profile, name="get_public_user_profile"), +] + +# Register ViewSets +from rest_framework.routers import DefaultRouter +from . import views_credits +from django.urls import include + +router = DefaultRouter() +router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit") + +urlpatterns += [ + path("", include(router.urls)), ] diff --git a/backend/apps/api/v1/accounts/views.py b/backend/apps/api/v1/accounts/views.py index fd43565f..33602ff6 100644 --- a/backend/apps/api/v1/accounts/views.py +++ b/backend/apps/api/v1/accounts/views.py @@ -8,6 +8,7 @@ preferences, privacy, notifications, and security. from apps.api.v1.serializers.accounts import ( CompleteUserSerializer, + PublicUserSerializer, UserPreferencesSerializer, NotificationSettingsSerializer, PrivacySettingsSerializer, @@ -23,6 +24,7 @@ from apps.api.v1.serializers.accounts import ( AvatarUploadSerializer, ) from apps.accounts.services import UserDeletionService +from apps.accounts.export_service import UserExportService from apps.accounts.models import ( User, UserProfile, @@ -1583,6 +1585,57 @@ def upload_avatar(request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@extend_schema( + operation_id="export_user_data", + summary="Export all user data", + description="Generate a JSON dump of all user data including profile, reviews, and lists.", + responses={ + 200: { + "description": "User data export", + "example": { + "account": {"username": "user", "email": "user@example.com"}, + "profile": {"display_name": "User"}, + "content": {"park_reviews": [], "lists": []} + } + }, + 401: {"description": "Authentication required"}, + }, + tags=["Self-Service Account Management"], +) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def export_user_data(request): + """Export all user data as JSON.""" + try: + export_data = UserExportService.export_user_data(request.user) + return Response(export_data, status=status.HTTP_200_OK) + except Exception as e: + logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True) + return Response( + {"error": "Failed to generate data export"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@extend_schema( + operation_id="get_public_user_profile", + summary="Get public user profile", + description="Get the public profile of a user by username.", + responses={ + 200: PublicUserSerializer, + 404: {"description": "User not found"}, + }, + tags=["User Profile"], +) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def get_public_user_profile(request, username): + """Get public user profile by username.""" + user = get_object_or_404(User, username=username) + serializer = PublicUserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) + + # === MISSING FUNCTION IMPLEMENTATIONS === diff --git a/backend/apps/api/v1/accounts/views_credits.py b/backend/apps/api/v1/accounts/views_credits.py new file mode 100644 index 00000000..1dcc5305 --- /dev/null +++ b/backend/apps/api/v1/accounts/views_credits.py @@ -0,0 +1,51 @@ +from rest_framework import viewsets, permissions, filters +from django_filters.rest_framework import DjangoFilterBackend +from apps.rides.models.credits import RideCredit +from apps.api.v1.serializers.ride_credits import RideCreditSerializer +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + +class RideCreditViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing Ride Credits. + Allows users to track rides they have ridden. + """ + serializer_class = RideCreditSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug'] + ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating'] + ordering = ['-last_ridden_at'] + + def get_queryset(self): + """ + Return ride credits. + Optionally filter by user via query param ?user=username + """ + queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user') + + # Filter by user if provided + username = self.request.query_params.get('user') + if username: + queryset = queryset.filter(user__username=username) + + return queryset + + def perform_create(self, serializer): + """Associate the current user with the ride credit.""" + serializer.save(user=self.request.user) + + @extend_schema( + summary="List ride credits", + description="List ride credits. filter by user username.", + parameters=[ + OpenApiParameter( + name="user", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Filter by username", + ), + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) diff --git a/backend/apps/api/v1/auth/serializers.py b/backend/apps/api/v1/auth/serializers.py index 1caa368f..dd87e5e8 100644 --- a/backend/apps/api/v1/auth/serializers.py +++ b/backend/apps/api/v1/auth/serializers.py @@ -37,16 +37,7 @@ def _normalize_email(value: str) -> str: class ModelChoices: """Model choices utility class.""" - @staticmethod - def get_top_list_categories(): - """Get top list category choices.""" - return [ - ("RC", "Roller Coasters"), - ("DR", "Dark Rides"), - ("FR", "Flat Rides"), - ("WR", "Water Rides"), - ("PK", "Parks"), - ] + # === AUTHENTICATION SERIALIZERS === @@ -480,129 +471,4 @@ class UserProfileUpdateInputSerializer(serializers.Serializer): water_ride_credits = serializers.IntegerField(required=False) -# === TOP LIST SERIALIZERS === - -@extend_schema_serializer( - examples=[ - OpenApiExample( - "Top List Example", - summary="Example top list response", - description="A user's top list of rides or parks", - value={ - "id": 1, - "title": "My Top 10 Roller Coasters", - "category": "RC", - "description": "My favorite roller coasters ranked", - "user": {"username": "coaster_fan", "display_name": "Coaster Fan"}, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-08-15T12:00:00Z", - }, - ) - ] -) -class TopListOutputSerializer(serializers.Serializer): - """Output serializer for top lists.""" - - id = serializers.IntegerField() - title = serializers.CharField() - category = serializers.CharField() - description = serializers.CharField() - created_at = serializers.DateTimeField() - updated_at = serializers.DateTimeField() - - # User info - user = serializers.SerializerMethodField() - - @extend_schema_field(serializers.DictField()) - def get_user(self, obj) -> Dict[str, Any]: - return { - "username": obj.user.username, - "display_name": obj.user.get_display_name(), - } - - -class TopListCreateInputSerializer(serializers.Serializer): - """Input serializer for creating top lists.""" - - title = serializers.CharField(max_length=100) - category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories()) - description = serializers.CharField(allow_blank=True, default="") - - -class TopListUpdateInputSerializer(serializers.Serializer): - """Input serializer for updating top lists.""" - - title = serializers.CharField(max_length=100, required=False) - category = serializers.ChoiceField( - choices=ModelChoices.get_top_list_categories(), required=False - ) - description = serializers.CharField(allow_blank=True, required=False) - - -# === TOP LIST ITEM SERIALIZERS === - - -@extend_schema_serializer( - examples=[ - OpenApiExample( - "Top List Item Example", - summary="Example top list item response", - description="An item in a user's top list", - value={ - "id": 1, - "rank": 1, - "notes": "Amazing airtime and smooth ride", - "object_name": "Steel Vengeance", - "object_type": "Ride", - "top_list": {"id": 1, "title": "My Top 10 Roller Coasters"}, - }, - ) - ] -) -class TopListItemOutputSerializer(serializers.Serializer): - """Output serializer for top list items.""" - - id = serializers.IntegerField() - rank = serializers.IntegerField() - notes = serializers.CharField() - object_name = serializers.SerializerMethodField() - object_type = serializers.SerializerMethodField() - - # Top list info - top_list = serializers.SerializerMethodField() - - @extend_schema_field(serializers.CharField()) - def get_object_name(self, obj) -> str: - """Get the name of the referenced object.""" - # This would need to be implemented based on the generic foreign key - return "Object Name" # Placeholder - - @extend_schema_field(serializers.CharField()) - def get_object_type(self, obj) -> str: - """Get the type of the referenced object.""" - return obj.content_type.model_class().__name__ - - @extend_schema_field(serializers.DictField()) - def get_top_list(self, obj) -> Dict[str, Any]: - return { - "id": obj.top_list.id, - "title": obj.top_list.title, - } - - -class TopListItemCreateInputSerializer(serializers.Serializer): - """Input serializer for creating top list items.""" - - top_list_id = serializers.IntegerField() - content_type_id = serializers.IntegerField() - object_id = serializers.IntegerField() - rank = serializers.IntegerField(min_value=1) - notes = serializers.CharField(allow_blank=True, default="") - - -class TopListItemUpdateInputSerializer(serializers.Serializer): - """Input serializer for updating top list items.""" - - rank = serializers.IntegerField(min_value=1, required=False) - notes = serializers.CharField(allow_blank=True, required=False) diff --git a/backend/apps/api/v1/images/urls.py b/backend/apps/api/v1/images/urls.py new file mode 100644 index 00000000..5096b485 --- /dev/null +++ b/backend/apps/api/v1/images/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import GenerateUploadURLView + +urlpatterns = [ + path("generate-upload-url/", GenerateUploadURLView.as_view(), name="generate-upload-url"), +] diff --git a/backend/apps/api/v1/images/views.py b/backend/apps/api/v1/images/views.py new file mode 100644 index 00000000..8951ad32 --- /dev/null +++ b/backend/apps/api/v1/images/views.py @@ -0,0 +1,37 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework import status +from apps.core.utils.cloudflare import get_direct_upload_url +from django.core.exceptions import ImproperlyConfigured +import requests +import logging + +logger = logging.getLogger(__name__) + +class GenerateUploadURLView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + # Pass user_id for metadata if needed + result = get_direct_upload_url(user_id=str(request.user.id)) + return Response(result, status=status.HTTP_200_OK) + except ImproperlyConfigured as e: + logger.error(f"Configuration Error: {e}") + return Response( + {"detail": "Server configuration error."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + except requests.RequestException as e: + logger.error(f"Cloudflare API Error: {e}") + return Response( + {"detail": "Failed to generate upload URL."}, + status=status.HTTP_502_BAD_GATEWAY + ) + except Exception as e: + logger.exception("Unexpected error generating upload URL") + return Response( + {"detail": "An unexpected error occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/backend/apps/api/v1/parks/history_views.py b/backend/apps/api/v1/parks/history_views.py new file mode 100644 index 00000000..f3e29e50 --- /dev/null +++ b/backend/apps/api/v1/parks/history_views.py @@ -0,0 +1,88 @@ +""" +Park history API views. +""" + +from rest_framework import viewsets, mixins +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema +from apps.parks.models import Park +from apps.rides.models import Ride +from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer + +class ParkHistoryViewSet(viewsets.GenericViewSet): + """ + ViewSet for retrieving park history. + """ + permission_classes = [AllowAny] + lookup_field = "slug" + lookup_url_kwarg = "park_slug" + + @extend_schema( + summary="Get park history", + description="Retrieve history events for a park.", + responses={200: ParkHistoryOutputSerializer}, + tags=["Park History"], + ) + def list(self, request, park_slug=None): + park = get_object_or_404(Park, slug=park_slug) + + events = [] + if hasattr(park, "events"): + events = park.events.all().order_by("-pgh_created_at") + + summary = { + "total_events": len(events), + "first_recorded": events.last().pgh_created_at if len(events) else None, + "last_modified": events.first().pgh_created_at if len(events) else None, + } + + data = { + "park": park, + "current_state": park, + "summary": summary, + "events": events + } + + serializer = ParkHistoryOutputSerializer(data) + return Response(serializer.data) + + +class RideHistoryViewSet(viewsets.GenericViewSet): + """ + ViewSet for retrieving ride history. + """ + permission_classes = [AllowAny] + lookup_field = "slug" + lookup_url_kwarg = "ride_slug" + + @extend_schema( + summary="Get ride history", + description="Retrieve history events for a ride.", + responses={200: RideHistoryOutputSerializer}, + tags=["Ride History"], + ) + def list(self, request, park_slug=None, ride_slug=None): + park = get_object_or_404(Park, slug=park_slug) + ride = get_object_or_404(Ride, slug=ride_slug, park=park) + + events = [] + if hasattr(ride, "events"): + events = ride.events.all().order_by("-pgh_created_at") + + summary = { + "total_events": len(events), + "first_recorded": events.last().pgh_created_at if len(events) else None, + "last_modified": events.first().pgh_created_at if len(events) else None, + } + + data = { + "ride": ride, + "current_state": ride, + "summary": summary, + "events": events + } + + serializer = RideHistoryOutputSerializer(data) + return Response(serializer.data) diff --git a/backend/apps/api/v1/parks/park_reviews_views.py b/backend/apps/api/v1/parks/park_reviews_views.py new file mode 100644 index 00000000..217ed188 --- /dev/null +++ b/backend/apps/api/v1/parks/park_reviews_views.py @@ -0,0 +1,162 @@ +""" +Park review API views for ThrillWiki API v1. + +This module contains park review ViewSet following the reviews pattern. +Provides CRUD operations for park reviews nested under parks/{slug}/reviews/ +""" + +import logging +from django.core.exceptions import PermissionDenied +from django.db.models import Avg +from django.utils import timezone +from drf_spectacular.utils import extend_schema_view, extend_schema +from drf_spectacular.types import OpenApiTypes +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError, NotFound +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.parks.models import Park, ParkReview +from apps.api.v1.serializers.park_reviews import ( + ParkReviewOutputSerializer, + ParkReviewCreateInputSerializer, + ParkReviewUpdateInputSerializer, + ParkReviewListOutputSerializer, + ParkReviewStatsOutputSerializer, + ParkReviewModerationInputSerializer, +) + +logger = logging.getLogger(__name__) + + +@extend_schema_view( + list=extend_schema( + summary="List park reviews", + tags=["Park Reviews"], + ), + create=extend_schema( + summary="Create park review", + tags=["Park Reviews"], + ), + retrieve=extend_schema( + summary="Get park review details", + tags=["Park Reviews"], + ), + update=extend_schema( + summary="Update park review", + tags=["Park Reviews"], + ), + partial_update=extend_schema( + summary="Partially update park review", + tags=["Park Reviews"], + ), + destroy=extend_schema( + summary="Delete park review", + tags=["Park Reviews"], + ), +) +class ParkReviewViewSet(ModelViewSet): + """ + ViewSet for managing park reviews with full CRUD operations. + """ + + lookup_field = "id" + + def get_permissions(self): + """Set permissions based on action.""" + if self.action in ['list', 'retrieve', 'stats']: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + def get_queryset(self): + """Get reviews for the current park.""" + queryset = ParkReview.objects.select_related( + "park", "user", "user__profile" + ) + + park_slug = self.kwargs.get("park_slug") + if park_slug: + try: + park, _ = Park.get_by_slug(park_slug) + queryset = queryset.filter(park=park) + except Park.DoesNotExist: + return queryset.none() + + if not (hasattr(self.request, 'user') and getattr(self.request.user, 'is_staff', False)): + queryset = queryset.filter(is_published=True) + + return queryset.order_by("-created_at") + + def get_serializer_class(self): + if self.action == "list": + return ParkReviewListOutputSerializer + elif self.action == "create": + return ParkReviewCreateInputSerializer + elif self.action in ["update", "partial_update"]: + return ParkReviewUpdateInputSerializer + else: + return ParkReviewOutputSerializer + + def perform_create(self, serializer): + park_slug = self.kwargs.get("park_slug") + try: + park, _ = Park.get_by_slug(park_slug) + except Park.DoesNotExist: + raise NotFound("Park not found") + + if ParkReview.objects.filter(park=park, user=self.request.user).exists(): + raise ValidationError("You have already reviewed this park") + + serializer.save( + park=park, + user=self.request.user, + is_published=True + ) + + def perform_update(self, serializer): + instance = self.get_object() + if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)): + raise PermissionDenied("You can only edit your own reviews.") + serializer.save() + + def perform_destroy(self, instance): + if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)): + raise PermissionDenied("You can only delete your own reviews.") + instance.delete() + + @extend_schema( + summary="Get park review statistics", + responses={200: ParkReviewStatsOutputSerializer}, + tags=["Park Reviews"], + ) + @action(detail=False, methods=["get"]) + def stats(self, request, park_slug=None): + try: + park, _ = Park.get_by_slug(park_slug) + except Park.DoesNotExist: + return Response({"error": "Park not found"}, status=status.HTTP_404_NOT_FOUND) + + reviews = ParkReview.objects.filter(park=park, is_published=True) + total_reviews = reviews.count() + avg_rating = reviews.aggregate(avg=Avg('rating'))['avg'] + + rating_distribution = {} + for i in range(1, 11): + rating_distribution[str(i)] = reviews.filter(rating=i).count() + + from datetime import timedelta + recent_reviews = reviews.filter(created_at__gte=timezone.now() - timedelta(days=30)).count() + + stats = { + "total_reviews": total_reviews, + "published_reviews": total_reviews, + "pending_reviews": ParkReview.objects.filter(park=park, is_published=False).count(), + "average_rating": avg_rating, + "rating_distribution": rating_distribution, + "recent_reviews": recent_reviews, + } + return Response(ParkReviewStatsOutputSerializer(stats).data) diff --git a/backend/apps/api/v1/parks/urls.py b/backend/apps/api/v1/parks/urls.py index f32e295a..c08e8191 100644 --- a/backend/apps/api/v1/parks/urls.py +++ b/backend/apps/api/v1/parks/urls.py @@ -42,10 +42,19 @@ router.register(r"", ParkPhotoViewSet, basename="park-photo") # Create routers for nested ride endpoints ride_photos_router = DefaultRouter() ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo") +from .ride_reviews_views import RideReviewViewSet ride_reviews_router = DefaultRouter() ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review") +from .park_reviews_views import ParkReviewViewSet +from .history_views import ParkHistoryViewSet, RideHistoryViewSet + +# Create routers for nested park endpoints +reviews_router = DefaultRouter() +reviews_router.register(r"", ParkReviewViewSet, basename="park-review") + + app_name = "api_v1_parks" urlpatterns = [ @@ -86,7 +95,7 @@ urlpatterns = [ name="park-image-settings", ), # Park photo endpoints - domain-specific photo management - path("/photos/", include(router.urls)), + path("/photos/", include(router.urls)), # Nested ride photo endpoints - photos for specific rides within parks path("/rides//photos/", include(ride_photos_router.urls)), @@ -95,6 +104,15 @@ urlpatterns = [ path("/rides//reviews/", include(ride_reviews_router.urls)), # Nested ride review endpoints - reviews for specific rides within parks path("/rides//reviews/", include(ride_reviews_router.urls)), + + # Ride History + path("/rides//history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"), + + # Park Reviews + path("/reviews/", include(reviews_router.urls)), + + # Park History + path("/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"), # Roadtrip API endpoints path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"), diff --git a/backend/apps/api/v1/parks/views.py b/backend/apps/api/v1/parks/views.py index 4b57b4e6..d58568a1 100644 --- a/backend/apps/api/v1/parks/views.py +++ b/backend/apps/api/v1/parks/views.py @@ -142,10 +142,14 @@ class ParkPhotoViewSet(ModelViewSet): "park", "park__operator", "uploaded_by" ) + # If park_pk is provided in URL kwargs, filter by park # If park_pk is provided in URL kwargs, filter by park park_pk = self.kwargs.get("park_pk") if park_pk: - queryset = queryset.filter(park_id=park_pk) + if str(park_pk).isdigit(): + queryset = queryset.filter(park_id=park_pk) + else: + queryset = queryset.filter(park__slug=park_pk) return queryset.order_by("-created_at") @@ -164,10 +168,16 @@ class ParkPhotoViewSet(ModelViewSet): """Create a new park photo using ParkMediaService.""" park_id = self.kwargs.get("park_pk") if not park_id: - raise ValidationError("Park ID is required") + raise ValidationError("Park ID/Slug is required") try: - Park.objects.get(pk=park_id) + if str(park_id).isdigit(): + park = Park.objects.get(pk=park_id) + else: + park = Park.objects.get(slug=park_id) + + # Use real park ID + park_id = park.id except Park.DoesNotExist: raise ValidationError("Park not found") @@ -342,7 +352,10 @@ class ParkPhotoViewSet(ModelViewSet): # Filter photos to only those belonging to this park (if park_pk provided) photos_queryset = ParkPhoto.objects.filter(id__in=photo_ids) if park_id: - photos_queryset = photos_queryset.filter(park_id=park_id) + if str(park_id).isdigit(): + photos_queryset = photos_queryset.filter(park_id=park_id) + else: + photos_queryset = photos_queryset.filter(park__slug=park_id) updated_count = photos_queryset.update(is_approved=approve) @@ -385,10 +398,13 @@ class ParkPhotoViewSet(ModelViewSet): park = None if park_pk: try: - park = Park.objects.get(pk=park_pk) + if str(park_pk).isdigit(): + park = Park.objects.get(pk=park_pk) + else: + park = Park.objects.get(slug=park_pk) except Park.DoesNotExist: return ErrorHandler.handle_api_error( - NotFoundError(f"Park with id {park_pk} not found"), + NotFoundError(f"Park with id/slug {park_pk} not found"), user_message="Park not found", status_code=status.HTTP_404_NOT_FOUND, ) @@ -474,7 +490,10 @@ class ParkPhotoViewSet(ModelViewSet): ) try: - park = Park.objects.get(pk=park_pk) + if str(park_pk).isdigit(): + park = Park.objects.get(pk=park_pk) + else: + park = Park.objects.get(slug=park_pk) except Park.DoesNotExist: return Response( {"error": "Park not found"}, diff --git a/backend/apps/api/v1/serializers.py b/backend/apps/api/v1/serializers.py index c194cb72..5f5a2b89 100644 --- a/backend/apps/api/v1/serializers.py +++ b/backend/apps/api/v1/serializers.py @@ -30,12 +30,7 @@ AuthStatusOutputSerializer: Any = None UserProfileCreateInputSerializer: Any = None UserProfileUpdateInputSerializer: Any = None UserProfileOutputSerializer: Any = None -TopListCreateInputSerializer: Any = None -TopListUpdateInputSerializer: Any = None -TopListOutputSerializer: Any = None -TopListItemCreateInputSerializer: Any = None -TopListItemUpdateInputSerializer: Any = None -TopListItemOutputSerializer: Any = None + # Explicit __all__ for static analysis β€” update this list if new serializers are added. __all__ = ( @@ -54,10 +49,5 @@ __all__ = ( "UserProfileCreateInputSerializer", "UserProfileUpdateInputSerializer", "UserProfileOutputSerializer", - "TopListCreateInputSerializer", - "TopListUpdateInputSerializer", - "TopListOutputSerializer", - "TopListItemCreateInputSerializer", - "TopListItemUpdateInputSerializer", - "TopListItemOutputSerializer", + ) diff --git a/backend/apps/api/v1/serializers/__init__.py b/backend/apps/api/v1/serializers/__init__.py index 4a6369bd..83eab70f 100644 --- a/backend/apps/api/v1/serializers/__init__.py +++ b/backend/apps/api/v1/serializers/__init__.py @@ -90,12 +90,7 @@ _ACCOUNTS_SYMBOLS: List[str] = [ "UserProfileOutputSerializer", "UserProfileCreateInputSerializer", "UserProfileUpdateInputSerializer", - "TopListOutputSerializer", - "TopListCreateInputSerializer", - "TopListUpdateInputSerializer", - "TopListItemOutputSerializer", - "TopListItemCreateInputSerializer", - "TopListItemUpdateInputSerializer", + "UserOutputSerializer", "LoginInputSerializer", "LoginOutputSerializer", diff --git a/backend/apps/api/v1/serializers/accounts.py b/backend/apps/api/v1/serializers/accounts.py index 6de6961f..5237d070 100644 --- a/backend/apps/api/v1/serializers/accounts.py +++ b/backend/apps/api/v1/serializers/accounts.py @@ -18,6 +18,7 @@ from apps.accounts.models import ( NotificationPreference, ) from apps.lists.models import UserList +from apps.rides.models.credits import RideCredit from apps.core.choices.serializers import RichChoiceFieldSerializer UserModel = get_user_model() @@ -66,6 +67,8 @@ class UserProfileSerializer(serializers.ModelSerializer): avatar_url = serializers.SerializerMethodField() avatar_variants = serializers.SerializerMethodField() + total_credits = serializers.SerializerMethodField() + unique_parks = serializers.SerializerMethodField() class Meta: model = UserProfile @@ -87,8 +90,19 @@ class UserProfileSerializer(serializers.ModelSerializer): "water_ride_credits", "unit_system", "location", + "total_credits", + "unique_parks", ] - read_only_fields = ["profile_id", "avatar_url", "avatar_variants"] + read_only_fields = ["profile_id", "avatar_url", "avatar_variants", "total_credits", "unique_parks"] + + def get_total_credits(self, obj): + """Get the total number of ride credits.""" + return RideCredit.objects.filter(user=obj.user).count() + + def get_unique_parks(self, obj): + """Get the number of unique parks visited.""" + # This assumes RideCredit -> Ride -> Park relationship + return RideCredit.objects.filter(user=obj.user).values("ride__park").distinct().count() def get_avatar_url(self, obj): """Get the avatar URL with fallback to default letter-based avatar.""" @@ -167,6 +181,25 @@ class CompleteUserSerializer(serializers.ModelSerializer): read_only_fields = ["user_id", "date_joined", "role"] +class PublicUserSerializer(serializers.ModelSerializer): + """ + Public user serializer for viewing other users' profiles. + Only exposes public information. + """ + profile = UserProfileSerializer(read_only=True) + + class Meta: + model = User + fields = [ + "user_id", + "username", + "date_joined", + "role", + "profile", + ] + read_only_fields = fields + + # === USER SETTINGS SERIALIZERS === diff --git a/backend/apps/api/v1/serializers/park_reviews.py b/backend/apps/api/v1/serializers/park_reviews.py new file mode 100644 index 00000000..e2910434 --- /dev/null +++ b/backend/apps/api/v1/serializers/park_reviews.py @@ -0,0 +1,171 @@ +""" +Serializers for park review API endpoints. + +This module contains serializers for park review CRUD operations. +""" + +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample +from apps.parks.models.reviews import ParkReview +from apps.api.v1.serializers.reviews import ReviewUserSerializer + +@extend_schema_serializer( + examples=[ + OpenApiExample( + name="Complete Park Review", + summary="Full park review response", + description="Example response showing all fields for a park review", + value={ + "id": 123, + "title": "Great family park!", + "content": "We had a wonderful time. The atmosphere is charming.", + "rating": 9, + "visit_date": "2023-06-15", + "created_at": "2023-06-16T10:30:00Z", + "updated_at": "2023-06-16T10:30:00Z", + "is_published": True, + "user": { + "username": "park_fan", + "display_name": "Park Fan", + "avatar_url": "https://example.com/avatar.jpg" + }, + "park": { + "id": 101, + "name": "Cedar Point", + "slug": "cedar-point" + } + } + ) + ] +) +class ParkReviewOutputSerializer(serializers.ModelSerializer): + """Output serializer for park reviews.""" + + user = ReviewUserSerializer(read_only=True) + park = serializers.SerializerMethodField() + + class Meta: + model = ParkReview + fields = [ + "id", + "title", + "content", + "rating", + "visit_date", + "created_at", + "updated_at", + "is_published", + "user", + "park", + ] + read_only_fields = [ + "id", + "created_at", + "updated_at", + "user", + "park", + ] + + @extend_schema_field(serializers.DictField()) + def get_park(self, obj): + """Get park information.""" + return { + "id": obj.park.id, + "name": obj.park.name, + "slug": obj.park.slug, + } + + +class ParkReviewCreateInputSerializer(serializers.ModelSerializer): + """Input serializer for creating park reviews.""" + + class Meta: + model = ParkReview + fields = [ + "title", + "content", + "rating", + "visit_date", + ] + + def validate_rating(self, value): + """Validate rating is between 1 and 10.""" + if not (1 <= value <= 10): + raise serializers.ValidationError("Rating must be between 1 and 10.") + return value + + +class ParkReviewUpdateInputSerializer(serializers.ModelSerializer): + """Input serializer for updating park reviews.""" + + class Meta: + model = ParkReview + fields = [ + "title", + "content", + "rating", + "visit_date", + ] + + def validate_rating(self, value): + """Validate rating is between 1 and 10.""" + if not (1 <= value <= 10): + raise serializers.ValidationError("Rating must be between 1 and 10.") + return value + + +class ParkReviewListOutputSerializer(serializers.ModelSerializer): + """Simplified output serializer for park review lists.""" + + user = ReviewUserSerializer(read_only=True) + park_name = serializers.CharField(source="park.name", read_only=True) + + class Meta: + model = ParkReview + fields = [ + "id", + "title", + "rating", + "visit_date", + "created_at", + "is_published", + "user", + "park_name", + ] + read_only_fields = fields + + +class ParkReviewStatsOutputSerializer(serializers.Serializer): + """Output serializer for park review statistics.""" + + total_reviews = serializers.IntegerField() + published_reviews = serializers.IntegerField() + pending_reviews = serializers.IntegerField() + average_rating = serializers.FloatField(allow_null=True) + rating_distribution = serializers.DictField( + child=serializers.IntegerField(), + help_text="Count of reviews by rating (1-10)" + ) + recent_reviews = serializers.IntegerField() + + +class ParkReviewModerationInputSerializer(serializers.Serializer): + """Input serializer for review moderation operations.""" + + review_ids = serializers.ListField( + child=serializers.IntegerField(), + help_text="List of review IDs to moderate" + ) + action = serializers.ChoiceField( + choices=[ + ("publish", "Publish"), + ("unpublish", "Unpublish"), + ("delete", "Delete"), + ], + help_text="Moderation action to perform" + ) + moderation_notes = serializers.CharField( + required=False, + allow_blank=True, + help_text="Optional notes about the moderation action" + ) diff --git a/backend/apps/api/v1/serializers/ride_credits.py b/backend/apps/api/v1/serializers/ride_credits.py new file mode 100644 index 00000000..38af8630 --- /dev/null +++ b/backend/apps/api/v1/serializers/ride_credits.py @@ -0,0 +1,47 @@ +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field +from apps.rides.models.credits import RideCredit +from apps.rides.models import Ride +from apps.api.v1.serializers.rides import RideListOutputSerializer + +class RideCreditSerializer(serializers.ModelSerializer): + """Serializer for user ride credits.""" + + ride_id = serializers.PrimaryKeyRelatedField( + queryset=Ride.objects.all(), source='ride', write_only=True + ) + ride = RideListOutputSerializer(read_only=True) + + class Meta: + model = RideCredit + fields = [ + 'id', + 'ride', + 'ride_id', + 'count', + 'rating', + 'first_ridden_at', + 'last_ridden_at', + 'notes', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def validate(self, attrs): + """ + Validate data. + """ + # Ensure dates make sense + first = attrs.get('first_ridden_at') + last = attrs.get('last_ridden_at') + if first and last and last < first: + raise serializers.ValidationError("Last ridden date cannot be before first ridden date.") + + return attrs + + def create(self, validated_data): + """Create a new ride credit.""" + user = self.context['request'].user + validated_data['user'] = user + return super().create(validated_data) diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index b916845a..5fcc44c6 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -16,6 +16,7 @@ from .views import ( NewContentAPIView, TriggerTrendingCalculationAPIView, ) +from .views.discovery import DiscoveryAPIView from .views.stats import StatsAPIView, StatsRecalculateAPIView from .views.reviews import LatestReviewsAPIView from django.urls import path, include @@ -44,6 +45,7 @@ urlpatterns = [ ), # Trending system endpoints path("trending/", TrendingAPIView.as_view(), name="trending"), + path("discovery/", DiscoveryAPIView.as_view(), name="discovery"), path("new-content/", NewContentAPIView.as_view(), name="new-content"), path( "trending/calculate/", @@ -75,6 +77,11 @@ urlpatterns = [ path("maps/", include("apps.api.v1.maps.urls")), path("lists/", include("apps.lists.urls")), path("moderation/", include("apps.moderation.urls")), + path("reviews/", include("apps.reviews.urls")), + path("media/", include("apps.media.urls")), + path("blog/", include("apps.blog.urls")), + path("support/", include("apps.support.urls")), + path("images/", include("apps.api.v1.images.urls")), # Cloudflare Images Toolkit API endpoints path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")), # Include router URLs (for rankings and any other router-registered endpoints) diff --git a/backend/apps/api/v1/views/discovery.py b/backend/apps/api/v1/views/discovery.py new file mode 100644 index 00000000..db70cb16 --- /dev/null +++ b/backend/apps/api/v1/views/discovery.py @@ -0,0 +1,96 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny +from django.db.models import F +from django.utils import timezone +from drf_spectacular.utils import extend_schema +from datetime import timedelta +from apps.parks.models import Park +from apps.rides.models import Ride + +class DiscoveryAPIView(APIView): + """ + API endpoint for discovery content (Top Lists, Opening/Closing Soon). + """ + permission_classes = [AllowAny] + + @extend_schema( + summary="Get discovery content", + description="Retrieve curated lists for discovery tabs (Top, Opening, Closing).", + responses={200: "object"}, + tags=["Discovery"], + ) + def get(self, request): + today = timezone.now().date() + limit = 10 + + # --- TOP LISTS --- + # Top Parks by average rating + top_parks = Park.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit] + + # Top Rides by average rating (fallback to RideRanking in future) + top_rides = Ride.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit] + + # --- OPENING --- + # Opening Soon (Future opening date) + opening_soon_parks = Park.objects.filter(opening_date__gt=today).order_by("opening_date")[:limit] + opening_soon_rides = Ride.objects.filter(opening_date__gt=today).order_by("opening_date")[:limit] + + # Recently Opened (Past opening date, descending) + recently_opened_parks = Park.objects.filter(opening_date__lte=today).order_by("-opening_date")[:limit] + recently_opened_rides = Ride.objects.filter(opening_date__lte=today).order_by("-opening_date")[:limit] + + # --- CLOSING --- + # Closing Soon (Future closing date) + closing_soon_parks = Park.objects.filter(closing_date__gt=today).order_by("closing_date")[:limit] + closing_soon_rides = Ride.objects.filter(closing_date__gt=today).order_by("closing_date")[:limit] + + # Recently Closed (Past closing date, descending) + recently_closed_parks = Park.objects.filter(closing_date__lte=today).order_by("-closing_date")[:limit] + recently_closed_rides = Ride.objects.filter(closing_date__lte=today).order_by("-closing_date")[:limit] + + data = { + "top_parks": self._serialize(top_parks, "park"), + "top_rides": self._serialize(top_rides, "ride"), + "opening_soon": { + "parks": self._serialize(opening_soon_parks, "park"), + "rides": self._serialize(opening_soon_rides, "ride"), + }, + "recently_opened": { + "parks": self._serialize(recently_opened_parks, "park"), + "rides": self._serialize(recently_opened_rides, "ride"), + }, + "closing_soon": { + "parks": self._serialize(closing_soon_parks, "park"), + "rides": self._serialize(closing_soon_rides, "ride"), + }, + "recently_closed": { + "parks": self._serialize(recently_closed_parks, "park"), + "rides": self._serialize(recently_closed_rides, "ride"), + } + } + + return Response(data) + + def _serialize(self, queryset, type_): + results = [] + for item in queryset: + data = { + "id": item.id, + "name": item.name, + "slug": item.slug, + "average_rating": item.average_rating, + } + if type_ == "park": + data.update({ + "city": item.location.city if item.location else None, + "state": item.location.state if item.location else None, + }) + elif type_ == "ride": + data.update({ + "park_name": item.park.name, + "park_slug": item.park.slug + }) + results.append(data) + return results diff --git a/backend/apps/blog/__init__.py b/backend/apps/blog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/blog/apps.py b/backend/apps/blog/apps.py new file mode 100644 index 00000000..4ad51811 --- /dev/null +++ b/backend/apps/blog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class BlogConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.blog" + verbose_name = "Blog" diff --git a/backend/apps/blog/migrations/0001_initial.py b/backend/apps/blog/migrations/0001_initial.py new file mode 100644 index 00000000..f382195a --- /dev/null +++ b/backend/apps/blog/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 5.1.6 on 2025-12-26 14:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("django_cloudflareimages_toolkit", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Tag", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("slug", models.SlugField(help_text="URL-friendly identifier", max_length=200, unique=True)), + ("name", models.CharField(max_length=50, unique=True)), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="Post", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(help_text="Name of the object", max_length=200)), + ("slug", models.SlugField(help_text="URL-friendly identifier", max_length=200, unique=True)), + ("title", models.CharField(max_length=255)), + ("content", models.TextField(help_text="Markdown content supported")), + ("excerpt", models.TextField(blank=True, help_text="Short summary for lists")), + ("published_at", models.DateTimeField(blank=True, db_index=True, null=True)), + ("is_published", models.BooleanField(db_index=True, default=False)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blog_posts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "image", + models.ForeignKey( + blank=True, + help_text="Featured image", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="blog_posts", + to="django_cloudflareimages_toolkit.cloudflareimage", + ), + ), + ("tags", models.ManyToManyField(blank=True, related_name="posts", to="blog.tag")), + ], + options={ + "ordering": ["-published_at", "-created_at"], + }, + ), + ] diff --git a/backend/apps/blog/migrations/__init__.py b/backend/apps/blog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/blog/models.py b/backend/apps/blog/models.py new file mode 100644 index 00000000..c490380c --- /dev/null +++ b/backend/apps/blog/models.py @@ -0,0 +1,45 @@ +from django.db import models +from django.conf import settings +from apps.core.models import SluggedModel +# Using string reference for CloudflareImage +from django_cloudflareimages_toolkit.models import CloudflareImage + +class Tag(SluggedModel): + name = models.CharField(max_length=50, unique=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + +class Post(SluggedModel): + title = models.CharField(max_length=255) + content = models.TextField(help_text="Markdown content supported") + excerpt = models.TextField(blank=True, help_text="Short summary for lists") + + image = models.ForeignKey( + CloudflareImage, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="blog_posts", + help_text="Featured image" + ) + + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="blog_posts" + ) + + published_at = models.DateTimeField(null=True, blank=True, db_index=True) + is_published = models.BooleanField(default=False, db_index=True) + + tags = models.ManyToManyField(Tag, blank=True, related_name="posts") + + class Meta: + ordering = ["-published_at", "-created_at"] + + def __str__(self): + return self.title diff --git a/backend/apps/blog/serializers.py b/backend/apps/blog/serializers.py new file mode 100644 index 00000000..7fe0d0d3 --- /dev/null +++ b/backend/apps/blog/serializers.py @@ -0,0 +1,60 @@ +from rest_framework import serializers +from .models import Post, Tag +from apps.accounts.serializers import UserSerializer +from apps.media.serializers import CloudflareImageSerializer +from django_cloudflareimages_toolkit.models import CloudflareImage + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ["id", "name", "slug"] + +class PostListSerializer(serializers.ModelSerializer): + """Lighter serializer for lists""" + author = UserSerializer(read_only=True) + tags = TagSerializer(many=True, read_only=True) + image = CloudflareImageSerializer(read_only=True) + + class Meta: + model = Post + fields = [ + "id", + "title", + "slug", + "excerpt", + "image", + "author", + "published_at", + "tags", + ] + +class PostDetailSerializer(serializers.ModelSerializer): + author = UserSerializer(read_only=True) + tags = TagSerializer(many=True, read_only=True) + image = CloudflareImageSerializer(read_only=True) + image_id = serializers.PrimaryKeyRelatedField( + queryset=CloudflareImage.objects.all(), + source='image', + write_only=True, + required=False, + allow_null=True + ) + + class Meta: + model = Post + fields = [ + "id", + "title", + "slug", + "content", + "excerpt", + "image", + "image_id", + "author", + "published_at", + "is_published", + "tags", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "slug", "created_at", "updated_at", "author"] diff --git a/backend/apps/blog/urls.py b/backend/apps/blog/urls.py new file mode 100644 index 00000000..d0774a15 --- /dev/null +++ b/backend/apps/blog/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import PostViewSet, TagViewSet + +router = DefaultRouter() +router.register(r"posts", PostViewSet, basename="post") +router.register(r"tags", TagViewSet, basename="tag") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/blog/views.py b/backend/apps/blog/views.py new file mode 100644 index 00000000..2b3f8c98 --- /dev/null +++ b/backend/apps/blog/views.py @@ -0,0 +1,42 @@ +from rest_framework import viewsets, permissions, filters +from django_filters.rest_framework import DjangoFilterBackend +from django.utils import timezone +from .models import Post, Tag +from .serializers import PostListSerializer, PostDetailSerializer, TagSerializer +from apps.core.permissions import IsStaffOrReadOnly + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + permission_classes = [permissions.AllowAny] + filter_backends = [filters.SearchFilter] + search_fields = ["name"] + pagination_class = None # Tags are usually few + +class PostViewSet(viewsets.ModelViewSet): + """ + Public API: Read Only (unless staff). + Only published posts unless staff. + """ + permission_classes = [IsStaffOrReadOnly] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + search_fields = ["title", "excerpt", "content"] + filterset_fields = ["tags__slug", "is_published"] + ordering_fields = ["published_at", "created_at"] + ordering = ["-published_at"] + lookup_field = "slug" + + def get_queryset(self): + qs = Post.objects.all() + # If not staff, filter only published and past posts + if not self.request.user.is_staff: + qs = qs.filter(is_published=True, published_at__lte=timezone.now()) + return qs.select_related("author", "image").prefetch_related("tags") + + def get_serializer_class(self): + if self.action == "list": + return PostListSerializer + return PostDetailSerializer + + def perform_create(self, serializer): + serializer.save(author=self.request.user) diff --git a/backend/apps/core/decorators/cache_decorators.py b/backend/apps/core/decorators/cache_decorators.py index 3a271512..e6fe648e 100644 --- a/backend/apps/core/decorators/cache_decorators.py +++ b/backend/apps/core/decorators/cache_decorators.py @@ -11,6 +11,7 @@ from django.http import HttpRequest, HttpResponseBase from django.utils.decorators import method_decorator from django.views.decorators.vary import vary_on_headers from django.views import View +from rest_framework.response import Response as DRFResponse from apps.core.services.enhanced_cache_service import EnhancedCacheService import logging @@ -81,6 +82,14 @@ def cache_api_response( "cache_hit": True, }, ) + + # If cached data is our dict format for DRF responses, reconstruct it + if isinstance(cached_response, dict) and '__drf_data__' in cached_response: + return DRFResponse( + data=cached_response['__drf_data__'], + status=cached_response.get('status', 200) + ) + return cached_response # Execute view and cache result @@ -90,8 +99,18 @@ def cache_api_response( # Only cache successful responses if hasattr(response, "status_code") and response.status_code == 200: + # For DRF responses, we must cache the data, not the response object + # because the response object is not rendered yet and cannot be pickled + if hasattr(response, 'data'): + cache_payload = { + '__drf_data__': response.data, + 'status': response.status_code + } + else: + cache_payload = response + getattr(cache_service, cache_backend + "_cache").set( - cache_key, response, timeout + cache_key, cache_payload, timeout ) logger.debug( f"Cached API response for view {view_func.__name__}", diff --git a/backend/apps/core/permissions.py b/backend/apps/core/permissions.py index 8180290c..384ad1e8 100644 --- a/backend/apps/core/permissions.py +++ b/backend/apps/core/permissions.py @@ -16,3 +16,13 @@ class IsOwnerOrReadOnly(permissions.BasePermission): if hasattr(obj, 'user'): return obj.user == request.user return False + +class IsStaffOrReadOnly(permissions.BasePermission): + """ + Custom permission to only allow staff to edit it. + """ + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + return request.user and request.user.is_staff diff --git a/backend/apps/core/services/entity_fuzzy_matching.py b/backend/apps/core/services/entity_fuzzy_matching.py index 7a104995..acdafacb 100644 --- a/backend/apps/core/services/entity_fuzzy_matching.py +++ b/backend/apps/core/services/entity_fuzzy_matching.py @@ -229,7 +229,6 @@ class EntityFuzzyMatcher: parks = Park.objects.filter( Q(name__icontains=query) | Q(slug__icontains=query.lower().replace(" ", "-")) - | Q(former_names__icontains=query) )[: self.MAX_CANDIDATES] for park in parks: @@ -249,7 +248,6 @@ class EntityFuzzyMatcher: rides = Ride.objects.select_related("park").filter( Q(name__icontains=query) | Q(slug__icontains=query.lower().replace(" ", "-")) - | Q(former_names__icontains=query) | Q(park__name__icontains=query) )[: self.MAX_CANDIDATES] diff --git a/backend/apps/core/tests/test_history.py b/backend/apps/core/tests/test_history.py new file mode 100644 index 00000000..f0f7fd7e --- /dev/null +++ b/backend/apps/core/tests/test_history.py @@ -0,0 +1,54 @@ + +import pytest +from django.contrib.auth import get_user_model +from apps.parks.models import Park, Company +import pghistory + +User = get_user_model() + +@pytest.mark.django_db +class TestTrackedModel: + """ + Tests for the TrackedModel base class and pghistory integration. + """ + + def test_create_history_tracking(self): + """Test that creating a model instance creates a history event.""" + user = User.objects.create_user(username="testuser", password="password") + company = Company.objects.create(name="Test Operator", roles=["OPERATOR"]) + + with pghistory.context(user=user.id): + park = Park.objects.create( + name="History Test Park", + description="Testing history", + operating_season="Summer", + operator=company + ) + + # Verify history using the helper method from TrackedModel + events = park.get_history() + assert events.count() == 1 + event = events.first() + assert event.pgh_obj_id == park.pk + + # Verify context was captured + # The middleware isn't running here, so we used pghistory.context explicitly + # But pghistory.context stores data in pgh_context field if configured? + # Let's check if the event has pgh_context + assert event.pgh_context["user"] == user.id + + def test_update_tracking(self): + company = Company.objects.create(name="Test Operator 2", roles=["OPERATOR"]) + park = Park.objects.create(name="Original", operator=company) + + # Initial create event + assert park.get_history().count() == 1 + + # Update + park.name = "Updated" + park.save() + + assert park.get_history().count() == 2 + latest = park.get_history().first() # Ordered by -pgh_created_at + assert latest.name == "Updated" + diff --git a/backend/apps/core/utils/cloudflare.py b/backend/apps/core/utils/cloudflare.py new file mode 100644 index 00000000..c6a81051 --- /dev/null +++ b/backend/apps/core/utils/cloudflare.py @@ -0,0 +1,53 @@ +import requests +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +import logging + +logger = logging.getLogger(__name__) + +def get_direct_upload_url(user_id=None): + """ + Generates a direct upload URL for Cloudflare Images. + + Args: + user_id (str, optional): The user ID to associate with the upload. + + Returns: + dict: A dictionary containing 'id' and 'uploadURL'. + + Raises: + ImproperlyConfigured: If Cloudflare settings are missing. + requests.RequestException: If the Cloudflare API request fails. + """ + account_id = getattr(settings, 'CLOUDFLARE_IMAGES_ACCOUNT_ID', None) + api_token = getattr(settings, 'CLOUDFLARE_IMAGES_API_TOKEN', None) + + if not account_id or not api_token: + raise ImproperlyConfigured( + "CLOUDFLARE_IMAGES_ACCOUNT_ID and CLOUDFLARE_IMAGES_API_TOKEN must be set." + ) + + url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2/direct_upload" + + headers = { + "Authorization": f"Bearer {api_token}", + } + + data = { + "requireSignedURLs": "false", + } + + if user_id: + data["metadata"] = f'{{"user_id": "{user_id}"}}' + + response = requests.post(url, headers=headers, data=data) + response.raise_for_status() + + result = response.json() + + if not result.get("success"): + error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message") + logger.error(f"Cloudflare Direct Upload Error: {error_msg}") + raise requests.RequestException(f"Cloudflare Error: {error_msg}") + + return result.get("result", {}) diff --git a/backend/apps/media/migrations/0001_initial.py b/backend/apps/media/migrations/0001_initial.py index 9a5067f7..bb6b8fba 100644 --- a/backend/apps/media/migrations/0001_initial.py +++ b/backend/apps/media/migrations/0001_initial.py @@ -1,8 +1,6 @@ -# Generated by Django 5.1.4 on 2025-08-13 21:35 +# Generated by Django 5.1.6 on 2025-12-26 14:30 import django.db.models.deletion -import apps.media.models -import apps.media.storage import pgtrigger.compiler import pgtrigger.migrations from django.conf import settings @@ -15,6 +13,7 @@ class Migration(migrations.Migration): dependencies = [ ("contenttypes", "0002_remove_content_type_name"), + ("django_cloudflareimages_toolkit", "0001_initial"), ("pghistory", "0006_delete_aggregateevent"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -23,88 +22,82 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Photo", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "image", - models.ImageField( - max_length=255, - storage=apps.media.storage.MediaStorage(), - upload_to=apps.media.models.photo_upload_path, - ), - ), - ("caption", models.CharField(blank=True, max_length=255)), - ("alt_text", models.CharField(blank=True, max_length=255)), - ("is_primary", models.BooleanField(default=False)), - ("is_approved", models.BooleanField(default=False)), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), - ("date_taken", models.DateTimeField(blank=True, null=True)), - ("object_id", models.PositiveIntegerField()), + ("object_id", models.PositiveIntegerField(help_text="ID of the item")), + ("caption", models.CharField(blank=True, help_text="Photo caption", max_length=255)), + ("is_public", models.BooleanField(default=True, help_text="Whether this photo is visible to others")), + ("source", models.CharField(blank=True, help_text="Source/Credit if applicable", max_length=100)), ( "content_type", models.ForeignKey( + help_text="Type of item this photo belongs to", on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype", ), ), ( - "uploaded_by", + "image", models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="uploaded_photos", + help_text="Cloudflare Image reference", + on_delete=django.db.models.deletion.CASCADE, + related_name="photos_usage", + to="django_cloudflareimages_toolkit.cloudflareimage", + ), + ), + ( + "user", + models.ForeignKey( + help_text="User who uploaded this photo", + on_delete=django.db.models.deletion.CASCADE, + related_name="photos", to=settings.AUTH_USER_MODEL, ), ), ], options={ - "ordering": ["-is_primary", "-created_at"], + "verbose_name": "Photo", + "verbose_name_plural": "Photos", + "ordering": ["-created_at"], + "abstract": False, }, ), migrations.CreateModel( name="PhotoEvent", fields=[ - ( - "pgh_id", - models.AutoField(primary_key=True, serialize=False), - ), + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), ("pgh_created_at", models.DateTimeField(auto_now_add=True)), ("pgh_label", models.TextField(help_text="The event label.")), ("id", models.BigIntegerField()), - ( - "image", - models.ImageField( - max_length=255, - storage=apps.media.storage.MediaStorage(), - upload_to=apps.media.models.photo_upload_path, - ), - ), - ("caption", models.CharField(blank=True, max_length=255)), - ("alt_text", models.CharField(blank=True, max_length=255)), - ("is_primary", models.BooleanField(default=False)), - ("is_approved", models.BooleanField(default=False)), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), - ("date_taken", models.DateTimeField(blank=True, null=True)), - ("object_id", models.PositiveIntegerField()), + ("object_id", models.PositiveIntegerField(help_text="ID of the item")), + ("caption", models.CharField(blank=True, help_text="Photo caption", max_length=255)), + ("is_public", models.BooleanField(default=True, help_text="Whether this photo is visible to others")), + ("source", models.CharField(blank=True, help_text="Source/Credit if applicable", max_length=100)), ( "content_type", models.ForeignKey( db_constraint=False, + help_text="Type of item this photo belongs to", on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", related_query_name="+", to="contenttypes.contenttype", ), ), + ( + "image", + models.ForeignKey( + db_constraint=False, + help_text="Cloudflare Image reference", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="django_cloudflareimages_toolkit.cloudflareimage", + ), + ), ( "pgh_context", models.ForeignKey( @@ -125,10 +118,10 @@ class Migration(migrations.Migration): ), ), ( - "uploaded_by", + "user", models.ForeignKey( db_constraint=False, - null=True, + help_text="User who uploaded this photo", on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", related_query_name="+", @@ -142,18 +135,15 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="photo", - index=models.Index( - fields=["content_type", "object_id"], - name="media_photo_content_0187f5_idx", - ), + index=models.Index(fields=["content_type", "object_id"], name="media_photo_content_0187f5_idx"), ), pgtrigger.migrations.AddTrigger( model_name="photo", trigger=pgtrigger.compiler.Trigger( name="insert_insert", sql=pgtrigger.compiler.UpsertTriggerSql( - func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;', - hash="[AWS-SECRET-REMOVED]", + func='INSERT INTO "media_photoevent" ("caption", "content_type_id", "created_at", "id", "image_id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "source", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."id", NEW."image_id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."source", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="05c2d557f631f80ebd4b37ffb1ba9a539fa54244", operation="INSERT", pgid="pgtrigger_insert_insert_e1ca0", table="media_photo", @@ -167,8 +157,8 @@ class Migration(migrations.Migration): name="update_update", sql=pgtrigger.compiler.UpsertTriggerSql( condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", - func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;', - hash="[AWS-SECRET-REMOVED]", + func='INSERT INTO "media_photoevent" ("caption", "content_type_id", "created_at", "id", "image_id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "source", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."id", NEW."image_id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."source", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="9a4caabe540c0fd782b9c148444c364e385327f4", operation="UPDATE", pgid="pgtrigger_update_update_6ff7d", table="media_photo", diff --git a/backend/apps/media/models.py b/backend/apps/media/models.py new file mode 100644 index 00000000..7ae749b4 --- /dev/null +++ b/backend/apps/media/models.py @@ -0,0 +1,57 @@ +from django.db import models +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from apps.core.history import TrackedModel +import pghistory +# Using string reference for CloudflareImage to avoid circular imports if possible, +# or direct import if safe. django-cloudflare-images-toolkit usually provides a field or model. +# Checking installed apps... it's "django_cloudflareimages_toolkit". +from django_cloudflareimages_toolkit.models import CloudflareImage + +@pghistory.track() +class Photo(TrackedModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="photos", + help_text="User who uploaded this photo", + ) + + # The actual image + image = models.ForeignKey( + CloudflareImage, + on_delete=models.CASCADE, + related_name="photos_usage", + help_text="Cloudflare Image reference" + ) + + # Generic relation to target object (Park, Ride, etc.) + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + help_text="Type of item this photo belongs to", + ) + object_id = models.PositiveIntegerField(help_text="ID of the item") + content_object = GenericForeignKey("content_type", "object_id") + + # Metadata + caption = models.CharField(max_length=255, blank=True, help_text="Photo caption") + is_public = models.BooleanField( + default=True, + help_text="Whether this photo is visible to others" + ) + + # We might want credit/source info if not taken by user + source = models.CharField(max_length=100, blank=True, help_text="Source/Credit if applicable") + + class Meta(TrackedModel.Meta): + verbose_name = "Photo" + verbose_name_plural = "Photos" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["content_type", "object_id"]), + ] + + def __str__(self): + return f"Photo by {self.user.username} for {self.content_object}" diff --git a/backend/apps/media/serializers.py b/backend/apps/media/serializers.py new file mode 100644 index 00000000..40d02ede --- /dev/null +++ b/backend/apps/media/serializers.py @@ -0,0 +1,62 @@ +from rest_framework import serializers +from .models import Photo +from apps.accounts.serializers import UserSerializer +from django_cloudflareimages_toolkit.models import CloudflareImage + +# We need a serializer for the CloudflareImage model too if we want to show variants +class CloudflareImageSerializer(serializers.ModelSerializer): + variants = serializers.JSONField(read_only=True) + + class Meta: + model = CloudflareImage + fields = ["id", "cloudflare_id", "variants"] + +class PhotoSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + image = CloudflareImageSerializer(read_only=True) + cloudflare_image_id = serializers.CharField(write_only=True) + + # Helper for frontend to get URLs easily + url = serializers.SerializerMethodField() + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = Photo + fields = [ + "id", + "user", + "image", + "cloudflare_image_id", + "content_type", + "object_id", + "caption", + "source", + "is_public", + "created_at", + "updated_at", + "url", + "thumbnail", + ] + read_only_fields = ["id", "user", "created_at", "updated_at"] + + def create(self, validated_data): + cloudflare_id = validated_data.pop("cloudflare_image_id", None) + if cloudflare_id: + # Get or create the CloudflareImage wrapper + # We assume it exists on CF side. We just need the DB record. + image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id) + validated_data["image"] = image + + return super().create(validated_data) + + def get_url(self, obj): + # Return public variant or default + if obj.image: + # Check if get_url method exists or we construct strictly + return getattr(obj.image, 'get_url', lambda x: None)('public') + return None + + def get_thumbnail(self, obj): + if obj.image: + return getattr(obj.image, 'get_url', lambda x: None)('thumbnail') + return None diff --git a/backend/apps/media/urls.py b/backend/apps/media/urls.py new file mode 100644 index 00000000..1fb20721 --- /dev/null +++ b/backend/apps/media/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import PhotoViewSet + +router = DefaultRouter() +router.register(r"photos", PhotoViewSet, basename="photo") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/media/views.py b/backend/apps/media/views.py new file mode 100644 index 00000000..baf54e49 --- /dev/null +++ b/backend/apps/media/views.py @@ -0,0 +1,23 @@ +from rest_framework import viewsets, permissions, filters +from django_filters.rest_framework import DjangoFilterBackend +from .models import Photo +from .serializers import PhotoSerializer +from apps.core.permissions import IsOwnerOrReadOnly + +class PhotoViewSet(viewsets.ModelViewSet): + queryset = Photo.objects.filter(is_public=True) + serializer_class = PhotoSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["content_type", "object_id", "user"] + ordering_fields = ["created_at"] + ordering = ["-created_at"] + + def get_queryset(self): + qs = Photo.objects.filter(is_public=True) + if self.request.user.is_authenticated: + qs = qs | Photo.objects.filter(user=self.request.user) + return qs.distinct() + + def perform_create(self, serializer): + serializer.save(user=self.request.user) diff --git a/backend/apps/moderation/apps.py b/backend/apps/moderation/apps.py index 129f6119..6f734d71 100644 --- a/backend/apps/moderation/apps.py +++ b/backend/apps/moderation/apps.py @@ -80,51 +80,51 @@ class ModerationConfig(AppConfig): PhotoSubmission, ) - # EditSubmission callbacks + # EditSubmission callbacks (transitions from CLAIMED state) register_callback( - EditSubmission, 'status', 'PENDING', 'APPROVED', + EditSubmission, 'status', 'CLAIMED', 'APPROVED', SubmissionApprovedNotification() ) register_callback( - EditSubmission, 'status', 'PENDING', 'APPROVED', + EditSubmission, 'status', 'CLAIMED', 'APPROVED', ModerationCacheInvalidation() ) register_callback( - EditSubmission, 'status', 'PENDING', 'REJECTED', + EditSubmission, 'status', 'CLAIMED', 'REJECTED', SubmissionRejectedNotification() ) register_callback( - EditSubmission, 'status', 'PENDING', 'REJECTED', + EditSubmission, 'status', 'CLAIMED', 'REJECTED', ModerationCacheInvalidation() ) register_callback( - EditSubmission, 'status', 'PENDING', 'ESCALATED', + EditSubmission, 'status', 'CLAIMED', 'ESCALATED', SubmissionEscalatedNotification() ) register_callback( - EditSubmission, 'status', 'PENDING', 'ESCALATED', + EditSubmission, 'status', 'CLAIMED', 'ESCALATED', ModerationCacheInvalidation() ) - # PhotoSubmission callbacks + # PhotoSubmission callbacks (transitions from CLAIMED state) register_callback( - PhotoSubmission, 'status', 'PENDING', 'APPROVED', + PhotoSubmission, 'status', 'CLAIMED', 'APPROVED', SubmissionApprovedNotification() ) register_callback( - PhotoSubmission, 'status', 'PENDING', 'APPROVED', + PhotoSubmission, 'status', 'CLAIMED', 'APPROVED', ModerationCacheInvalidation() ) register_callback( - PhotoSubmission, 'status', 'PENDING', 'REJECTED', + PhotoSubmission, 'status', 'CLAIMED', 'REJECTED', SubmissionRejectedNotification() ) register_callback( - PhotoSubmission, 'status', 'PENDING', 'REJECTED', + PhotoSubmission, 'status', 'CLAIMED', 'REJECTED', ModerationCacheInvalidation() ) register_callback( - PhotoSubmission, 'status', 'PENDING', 'ESCALATED', + PhotoSubmission, 'status', 'CLAIMED', 'ESCALATED', SubmissionEscalatedNotification() ) diff --git a/backend/apps/moderation/choices.py b/backend/apps/moderation/choices.py index 661d3992..074ee32d 100644 --- a/backend/apps/moderation/choices.py +++ b/backend/apps/moderation/choices.py @@ -22,12 +22,29 @@ EDIT_SUBMISSION_STATUSES = [ 'icon': 'clock', 'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200', 'sort_order': 1, - 'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'], + 'can_transition_to': ['CLAIMED'], # Must be claimed before any action 'requires_moderator': True, 'is_actionable': True }, category=ChoiceCategory.STATUS ), + RichChoice( + value="CLAIMED", + label="Claimed", + description="Submission has been claimed by a moderator for review", + metadata={ + 'color': 'blue', + 'icon': 'user-check', + 'css_class': 'bg-blue-100 text-blue-800 border-blue-200', + 'sort_order': 2, + # Note: PENDING not included to avoid cycle - unclaim uses direct status update + 'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'], + 'requires_moderator': True, + 'is_actionable': True, + 'is_locked': True # Indicates this submission is locked for editing by others + }, + category=ChoiceCategory.STATUS + ), RichChoice( value="APPROVED", label="Approved", @@ -36,7 +53,7 @@ EDIT_SUBMISSION_STATUSES = [ 'color': 'green', 'icon': 'check-circle', 'css_class': 'bg-green-100 text-green-800 border-green-200', - 'sort_order': 2, + 'sort_order': 3, 'can_transition_to': [], 'requires_moderator': True, 'is_actionable': False, @@ -52,7 +69,7 @@ EDIT_SUBMISSION_STATUSES = [ 'color': 'red', 'icon': 'x-circle', 'css_class': 'bg-red-100 text-red-800 border-red-200', - 'sort_order': 3, + 'sort_order': 4, 'can_transition_to': [], 'requires_moderator': True, 'is_actionable': False, @@ -68,7 +85,7 @@ EDIT_SUBMISSION_STATUSES = [ 'color': 'purple', 'icon': 'arrow-up', 'css_class': 'bg-purple-100 text-purple-800 border-purple-200', - 'sort_order': 4, + 'sort_order': 5, 'can_transition_to': ['APPROVED', 'REJECTED'], 'requires_moderator': True, 'is_actionable': True, diff --git a/backend/apps/moderation/migrations/0009_add_claim_fields.py b/backend/apps/moderation/migrations/0009_add_claim_fields.py new file mode 100644 index 00000000..1f4a6865 --- /dev/null +++ b/backend/apps/moderation/migrations/0009_add_claim_fields.py @@ -0,0 +1,201 @@ +# Generated by Django 5.1.6 on 2025-12-26 20:01 + +import apps.core.state_machine.fields +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("moderation", "0008_alter_bulkoperation_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="editsubmission", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="editsubmission", + name="update_update", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="photosubmission", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="photosubmission", + name="update_update", + ), + migrations.AddField( + model_name="editsubmission", + name="claimed_at", + field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True), + ), + migrations.AddField( + model_name="editsubmission", + name="claimed_by", + field=models.ForeignKey( + blank=True, + help_text="Moderator who has claimed this submission for review", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="claimed_edit_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="editsubmissionevent", + name="claimed_at", + field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True), + ), + migrations.AddField( + model_name="editsubmissionevent", + name="claimed_by", + field=models.ForeignKey( + blank=True, + db_constraint=False, + help_text="Moderator who has claimed this submission for review", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="photosubmission", + name="claimed_at", + field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True), + ), + migrations.AddField( + model_name="photosubmission", + name="claimed_by", + field=models.ForeignKey( + blank=True, + help_text="Moderator who has claimed this submission for review", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="claimed_photo_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="photosubmissionevent", + name="claimed_at", + field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True), + ), + migrations.AddField( + model_name="photosubmissionevent", + name="claimed_by", + field=models.ForeignKey( + blank=True, + db_constraint=False, + help_text="Moderator who has claimed this submission for review", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="editsubmissionevent", + name="status", + field=apps.core.state_machine.fields.RichFSMField( + allow_deprecated=False, + choice_group="edit_submission_statuses", + choices=[ + ("PENDING", "Pending"), + ("CLAIMED", "Claimed"), + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("ESCALATED", "Escalated"), + ], + default="PENDING", + domain="moderation", + max_length=20, + ), + ), + migrations.AlterField( + model_name="photosubmissionevent", + name="status", + field=apps.core.state_machine.fields.RichFSMField( + allow_deprecated=False, + choice_group="photo_submission_statuses", + choices=[ + ("PENDING", "Pending"), + ("CLAIMED", "Claimed"), + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("ESCALATED", "Escalated"), + ], + default="PENDING", + domain="moderation", + max_length=20, + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="editsubmission", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="947e1d596310a6e4aad4f30724fbd2e2294d977b", + operation="INSERT", + pgid="pgtrigger_insert_insert_2c796", + table="moderation_editsubmission", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="editsubmission", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="568618c5161ed78a9c72d751f1c312c64dea3994", + operation="UPDATE", + pgid="pgtrigger_update_update_ab38f", + table="moderation_editsubmission", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="photosubmission", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="483cca8949361fe83eb0a964f9f454c5d2c1ac22", + operation="INSERT", + pgid="pgtrigger_insert_insert_62865", + table="moderation_photosubmission", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="photosubmission", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="82c7edd7b108f50aed0b6b06e44786617792171c", + operation="UPDATE", + pgid="pgtrigger_update_update_9c311", + table="moderation_photosubmission", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/moderation/models.py b/backend/apps/moderation/models.py index f963e0c9..1c2092bf 100644 --- a/backend/apps/moderation/models.py +++ b/backend/apps/moderation/models.py @@ -143,6 +143,19 @@ class EditSubmission(StateMachineMixin, TrackedModel): blank=True, help_text="Notes from the moderator about this submission" ) + # Claim tracking for concurrency control + claimed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="claimed_edit_submissions", + help_text="Moderator who has claimed this submission for review", + ) + claimed_at = models.DateTimeField( + null=True, blank=True, help_text="When this submission was claimed" + ) + class Meta(TrackedModel.Meta): verbose_name = "Edit Submission" verbose_name_plural = "Edit Submissions" @@ -188,6 +201,54 @@ class EditSubmission(StateMachineMixin, TrackedModel): """Get the final changes to apply (moderator changes if available, otherwise original changes)""" return self.moderator_changes or self.changes + def claim(self, user: UserType) -> None: + """ + Claim this submission for review. + Transition: PENDING -> CLAIMED + + Args: + user: The moderator claiming this submission + + Raises: + ValidationError: If submission is not in PENDING state + """ + from django.core.exceptions import ValidationError + + if self.status != "PENDING": + raise ValidationError( + f"Cannot claim submission: current status is {self.status}, expected PENDING" + ) + + self.transition_to_claimed(user=user) + self.claimed_by = user + self.claimed_at = timezone.now() + self.save() + + def unclaim(self, user: UserType = None) -> None: + """ + Release claim on this submission. + Transition: CLAIMED -> PENDING + + Args: + user: The user initiating the unclaim (for audit) + + Raises: + ValidationError: If submission is not in CLAIMED state + """ + from django.core.exceptions import ValidationError + + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED" + ) + + # Set status directly (not via FSM transition to avoid cycle) + # This is intentional - the unclaim action is a special "rollback" operation + self.status = "PENDING" + self.claimed_by = None + self.claimed_at = None + self.save() + def approve(self, moderator: UserType, user=None) -> Optional[models.Model]: """ Approve this submission and apply the changes. @@ -204,9 +265,17 @@ class EditSubmission(StateMachineMixin, TrackedModel): ValueError: If submission cannot be approved ValidationError: If the data is invalid """ + from django.core.exceptions import ValidationError + # Use user parameter if provided (FSM convention) approver = user or moderator + # Validate state - must be CLAIMED before approval + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot approve submission: must be CLAIMED first (current status: {self.status})" + ) + model_class = self.content_type.model_class() if not model_class: raise ValueError("Could not resolve model class") @@ -263,9 +332,17 @@ class EditSubmission(StateMachineMixin, TrackedModel): reason: Reason for rejection user: Alternative parameter for FSM compatibility """ + from django.core.exceptions import ValidationError + # Use user parameter if provided (FSM convention) rejecter = user or moderator + # Validate state - must be CLAIMED before rejection + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot reject submission: must be CLAIMED first (current status: {self.status})" + ) + # Use FSM transition to update status self.transition_to_rejected(user=rejecter) self.handled_by = rejecter @@ -283,9 +360,17 @@ class EditSubmission(StateMachineMixin, TrackedModel): reason: Reason for escalation user: Alternative parameter for FSM compatibility """ + from django.core.exceptions import ValidationError + # Use user parameter if provided (FSM convention) escalator = user or moderator + # Validate state - must be CLAIMED before escalation + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})" + ) + # Use FSM transition to update status self.transition_to_escalated(user=escalator) self.handled_by = escalator @@ -747,6 +832,19 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): help_text="Notes from the moderator about this photo submission", ) + # Claim tracking for concurrency control + claimed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="claimed_photo_submissions", + help_text="Moderator who has claimed this submission for review", + ) + claimed_at = models.DateTimeField( + null=True, blank=True, help_text="When this submission was claimed" + ) + class Meta(TrackedModel.Meta): verbose_name = "Photo Submission" verbose_name_plural = "Photo Submissions" @@ -759,6 +857,54 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): def __str__(self) -> str: return f"Photo submission by {self.user.username} for {self.content_object}" + def claim(self, user: UserType) -> None: + """ + Claim this photo submission for review. + Transition: PENDING -> CLAIMED + + Args: + user: The moderator claiming this submission + + Raises: + ValidationError: If submission is not in PENDING state + """ + from django.core.exceptions import ValidationError + + if self.status != "PENDING": + raise ValidationError( + f"Cannot claim submission: current status is {self.status}, expected PENDING" + ) + + self.transition_to_claimed(user=user) + self.claimed_by = user + self.claimed_at = timezone.now() + self.save() + + def unclaim(self, user: UserType = None) -> None: + """ + Release claim on this photo submission. + Transition: CLAIMED -> PENDING + + Args: + user: The user initiating the unclaim (for audit) + + Raises: + ValidationError: If submission is not in CLAIMED state + """ + from django.core.exceptions import ValidationError + + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED" + ) + + # Set status directly (not via FSM transition to avoid cycle) + # This is intentional - the unclaim action is a special "rollback" operation + self.status = "PENDING" + self.claimed_by = None + self.claimed_at = None + self.save() + def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None: """ Approve the photo submission. @@ -771,10 +917,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): """ from apps.parks.models.media import ParkPhoto from apps.rides.models.media import RidePhoto + from django.core.exceptions import ValidationError # Use user parameter if provided (FSM convention) approver = user or moderator + # Validate state - must be CLAIMED before approval + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})" + ) + # Determine the correct photo model based on the content type model_class = self.content_type.model_class() if model_class.__name__ == "Park": @@ -810,9 +963,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): notes: Rejection reason user: Alternative parameter for FSM compatibility """ + from django.core.exceptions import ValidationError + # Use user parameter if provided (FSM convention) rejecter = user or moderator + # Validate state - must be CLAIMED before rejection + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})" + ) + # Use FSM transition to update status self.transition_to_rejected(user=rejecter) self.handled_by = rejecter # type: ignore @@ -839,9 +1000,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel): notes: Escalation reason user: Alternative parameter for FSM compatibility """ + from django.core.exceptions import ValidationError + # Use user parameter if provided (FSM convention) escalator = user or moderator + # Validate state - must be CLAIMED before escalation + if self.status != "CLAIMED": + raise ValidationError( + f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})" + ) + # Use FSM transition to update status self.transition_to_escalated(user=escalator) self.handled_by = escalator # type: ignore diff --git a/backend/apps/moderation/serializers.py b/backend/apps/moderation/serializers.py index 7bc69245..b27c636e 100644 --- a/backend/apps/moderation/serializers.py +++ b/backend/apps/moderation/serializers.py @@ -22,6 +22,7 @@ from .models import ( ModerationAction, BulkOperation, EditSubmission, + PhotoSubmission, ) User = get_user_model() @@ -65,6 +66,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer): """Serializer for EditSubmission with UI metadata for Nuxt frontend.""" submitted_by = UserBasicSerializer(source="user", read_only=True) + claimed_by = UserBasicSerializer(read_only=True) content_type_name = serializers.CharField( source="content_type.model", read_only=True ) @@ -91,6 +93,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer): "rejection_reason", "submitted_by", "reviewed_by", + "claimed_by", + "claimed_at", "created_at", "updated_at", "time_since_created", @@ -100,6 +104,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer): "created_at", "updated_at", "submitted_by", + "claimed_by", + "claimed_at", "status_color", "status_icon", "status_display", @@ -111,6 +117,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer): """Return hex color based on status for UI badges.""" colors = { "PENDING": "#f59e0b", # Amber + "CLAIMED": "#3b82f6", # Blue "APPROVED": "#10b981", # Emerald "REJECTED": "#ef4444", # Red "ESCALATED": "#8b5cf6", # Violet @@ -121,6 +128,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer): """Return Heroicons icon name based on status.""" icons = { "PENDING": "heroicons:clock", + "CLAIMED": "heroicons:user-circle", "APPROVED": "heroicons:check-circle", "REJECTED": "heroicons:x-circle", "ESCALATED": "heroicons:arrow-up-circle", @@ -148,6 +156,9 @@ class EditSubmissionListSerializer(serializers.ModelSerializer): submitted_by_username = serializers.CharField( source="user.username", read_only=True ) + claimed_by_username = serializers.CharField( + source="claimed_by.username", read_only=True, allow_null=True + ) content_type_name = serializers.CharField( source="content_type.model", read_only=True ) @@ -162,6 +173,8 @@ class EditSubmissionListSerializer(serializers.ModelSerializer): "content_type_name", "object_id", "submitted_by_username", + "claimed_by_username", + "claimed_at", "status_color", "status_icon", "created_at", @@ -171,6 +184,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer): def get_status_color(self, obj) -> str: colors = { "PENDING": "#f59e0b", + "CLAIMED": "#3b82f6", "APPROVED": "#10b981", "REJECTED": "#ef4444", "ESCALATED": "#8b5cf6", @@ -180,6 +194,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer): def get_status_icon(self, obj) -> str: icons = { "PENDING": "heroicons:clock", + "CLAIMED": "heroicons:user-circle", "APPROVED": "heroicons:check-circle", "REJECTED": "heroicons:x-circle", "ESCALATED": "heroicons:arrow-up-circle", @@ -911,3 +926,90 @@ class StateLogSerializer(serializers.ModelSerializer): ] read_only_fields = fields + +class PhotoSubmissionSerializer(serializers.ModelSerializer): + """Serializer for PhotoSubmission.""" + + submitted_by = UserBasicSerializer(source="user", read_only=True) + content_type_name = serializers.CharField( + source="content_type.model", read_only=True + ) + photo_url = serializers.SerializerMethodField() + + # UI Metadata + status_display = serializers.CharField(source="get_status_display", read_only=True) + status_color = serializers.SerializerMethodField() + status_icon = serializers.SerializerMethodField() + time_since_created = serializers.SerializerMethodField() + + class Meta: + model = PhotoSubmission + fields = [ + "id", + "status", + "status_display", + "status_color", + "status_icon", + "content_type", + "content_type_name", + "object_id", + "photo", + "photo_url", + "caption", + "date_taken", + "submitted_by", + "handled_by", + "handled_at", + "notes", + "created_at", + "time_since_created", + ] + read_only_fields = [ + "id", + "created_at", + "submitted_by", + "handled_by", + "handled_at", + "status_display", + "status_color", + "status_icon", + "content_type_name", + "photo_url", + "time_since_created", + ] + + def get_photo_url(self, obj) -> str | None: + if obj.photo: + return obj.photo.image_url + return None + + def get_status_color(self, obj) -> str: + colors = { + "PENDING": "#f59e0b", + "APPROVED": "#10b981", + "REJECTED": "#ef4444", + } + return colors.get(obj.status, "#6b7280") + + def get_status_icon(self, obj) -> str: + icons = { + "PENDING": "heroicons:clock", + "APPROVED": "heroicons:check-circle", + "REJECTED": "heroicons:x-circle", + } + return icons.get(obj.status, "heroicons:question-mark-circle") + + def get_time_since_created(self, obj) -> str: + """Human-readable time since creation.""" + now = timezone.now() + diff = now - obj.created_at + + if diff.days > 0: + return f"{diff.days} days ago" + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + return f"{hours} hours ago" + else: + minutes = diff.seconds // 60 + return f"{minutes} minutes ago" + diff --git a/backend/apps/moderation/signals.py b/backend/apps/moderation/signals.py index 47871582..662cbc0a 100644 --- a/backend/apps/moderation/signals.py +++ b/backend/apps/moderation/signals.py @@ -4,12 +4,17 @@ Signal handlers for moderation-related FSM state transitions. This module provides signal handlers that execute when moderation models (EditSubmission, PhotoSubmission, ModerationReport, etc.) undergo state transitions. + +Includes: +- Transition handlers for approval, rejection, escalation +- Real-time broadcasting signal for dashboard updates +- Claim/unclaim tracking for concurrency control """ import logging from django.conf import settings -from django.dispatch import receiver +from django.dispatch import receiver, Signal from apps.core.state_machine.signals import ( post_state_transition, @@ -20,6 +25,71 @@ from apps.core.state_machine.signals import ( logger = logging.getLogger(__name__) +# ============================================================================ +# Custom Signals for Real-Time Broadcasting +# ============================================================================ + +# Signal emitted when a submission status changes - for real-time UI updates +# Arguments: +# - sender: The model class (EditSubmission or PhotoSubmission) +# - submission_id: The ID of the submission +# - submission_type: "edit" or "photo" +# - new_status: The new status value +# - previous_status: The previous status value +# - locked_by: Username of the moderator who claimed it (or None) +# - payload: Full payload dictionary for broadcasting +submission_status_changed = Signal() + + +def handle_submission_claimed(instance, source, target, user, context=None, **kwargs): + """ + Handle submission claim transitions. + + Called when an EditSubmission or PhotoSubmission is claimed by a moderator. + Broadcasts the status change for real-time dashboard updates. + + Args: + instance: The submission instance. + source: The source state. + target: The target state. + user: The user who claimed. + context: Optional TransitionContext. + """ + if target != 'CLAIMED': + return + + logger.info( + f"Submission {instance.pk} claimed by {user.username if user else 'system'}" + ) + + # Broadcast for real-time dashboard updates + _broadcast_submission_status_change(instance, source, target, user) + + +def handle_submission_unclaimed(instance, source, target, user, context=None, **kwargs): + """ + Handle submission unclaim transitions (CLAIMED -> PENDING). + + Called when a moderator releases their claim on a submission. + + Args: + instance: The submission instance. + source: The source state. + target: The target state. + user: The user who unclaimed. + context: Optional TransitionContext. + """ + if source != 'CLAIMED' or target != 'PENDING': + return + + logger.info( + f"Submission {instance.pk} unclaimed by {user.username if user else 'system'}" + ) + + # Broadcast for real-time dashboard updates + _broadcast_submission_status_change(instance, source, target, user) + + def handle_submission_approved(instance, source, target, user, context=None, **kwargs): """ Handle submission approval transitions. @@ -255,6 +325,66 @@ def _finalize_bulk_operation(instance, success): logger.warning(f"Failed to finalize bulk operation: {e}") +def _broadcast_submission_status_change(instance, source, target, user): + """ + Broadcast submission status change for real-time UI updates. + + Emits the submission_status_changed signal with a structured payload + that can be consumed by notification systems (Novu, SSE, WebSocket, etc.). + + Payload format: + { + "submission_id": 123, + "submission_type": "edit" | "photo", + "new_status": "CLAIMED", + "previous_status": "PENDING", + "locked_by": "moderator_username" | None, + "locked_at": "2024-01-01T12:00:00Z" | None, + "changed_by": "username" | None, + } + """ + try: + from .models import EditSubmission, PhotoSubmission + + # Determine submission type + submission_type = "edit" if isinstance(instance, EditSubmission) else "photo" + + # Build the broadcast payload + payload = { + "submission_id": instance.pk, + "submission_type": submission_type, + "new_status": target, + "previous_status": source, + "locked_by": None, + "locked_at": None, + "changed_by": user.username if user else None, + } + + # Add claim information if available + if hasattr(instance, 'claimed_by') and instance.claimed_by: + payload["locked_by"] = instance.claimed_by.username + if hasattr(instance, 'claimed_at') and instance.claimed_at: + payload["locked_at"] = instance.claimed_at.isoformat() + + # Emit the signal for downstream notification handlers + submission_status_changed.send( + sender=type(instance), + submission_id=instance.pk, + submission_type=submission_type, + new_status=target, + previous_status=source, + locked_by=payload["locked_by"], + payload=payload, + ) + + logger.debug( + f"Broadcast status change: {submission_type}#{instance.pk} " + f"{source} -> {target}" + ) + except Exception as e: + logger.warning(f"Failed to broadcast submission status change: {e}") + + # Signal handler registration def register_moderation_signal_handlers(): @@ -320,7 +450,41 @@ def register_moderation_signal_handlers(): handle_bulk_operation_status, stage='post' ) + # Claim/Unclaim handlers for EditSubmission + register_transition_handler( + EditSubmission, 'PENDING', 'CLAIMED', + handle_submission_claimed, stage='post' + ) + register_transition_handler( + EditSubmission, 'CLAIMED', 'PENDING', + handle_submission_unclaimed, stage='post' + ) + + # Claim/Unclaim handlers for PhotoSubmission + register_transition_handler( + PhotoSubmission, 'PENDING', 'CLAIMED', + handle_submission_claimed, stage='post' + ) + register_transition_handler( + PhotoSubmission, 'CLAIMED', 'PENDING', + handle_submission_unclaimed, stage='post' + ) + logger.info("Registered moderation signal handlers") except ImportError as e: logger.warning(f"Could not register moderation signal handlers: {e}") + + +__all__ = [ + 'submission_status_changed', + 'register_moderation_signal_handlers', + 'handle_submission_approved', + 'handle_submission_rejected', + 'handle_submission_escalated', + 'handle_submission_claimed', + 'handle_submission_unclaimed', + 'handle_report_resolved', + 'handle_queue_completed', + 'handle_bulk_operation_status', +] diff --git a/backend/apps/moderation/sse.py b/backend/apps/moderation/sse.py new file mode 100644 index 00000000..b5f74535 --- /dev/null +++ b/backend/apps/moderation/sse.py @@ -0,0 +1,185 @@ +""" +Server-Sent Events (SSE) endpoint for real-time moderation dashboard updates. + +This module provides a streaming HTTP response that broadcasts submission status +changes to connected moderators in real-time. +""" +import json +import logging +import queue +import threading +import time +from typing import Generator + +from django.http import StreamingHttpResponse, JsonResponse +from django.views import View +from django.contrib.auth.mixins import LoginRequiredMixin +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated + +from apps.moderation.permissions import CanViewModerationData +from apps.moderation.signals import submission_status_changed + +logger = logging.getLogger(__name__) + + +# Thread-safe queue for broadcasting events to all connected clients +class SSEBroadcaster: + """ + Manages SSE connections and broadcasts events to all clients. + + Uses a simple subscriber pattern where each connected client + gets its own queue of events to consume. + """ + + def __init__(self): + self._subscribers: list[queue.Queue] = [] + self._lock = threading.Lock() + + def subscribe(self) -> queue.Queue: + """Create a new subscriber queue and register it.""" + client_queue = queue.Queue() + with self._lock: + self._subscribers.append(client_queue) + logger.debug(f"SSE client subscribed. Total clients: {len(self._subscribers)}") + return client_queue + + def unsubscribe(self, client_queue: queue.Queue): + """Remove a subscriber queue.""" + with self._lock: + if client_queue in self._subscribers: + self._subscribers.remove(client_queue) + logger.debug(f"SSE client unsubscribed. Total clients: {len(self._subscribers)}") + + def broadcast(self, event_data: dict): + """Send an event to all connected clients.""" + with self._lock: + for client_queue in self._subscribers: + try: + client_queue.put_nowait(event_data) + except queue.Full: + logger.warning("SSE client queue full, dropping event") + + +# Global broadcaster instance +sse_broadcaster = SSEBroadcaster() + + +def handle_submission_status_changed(sender, payload, **kwargs): + """ + Signal handler that broadcasts submission status changes to SSE clients. + + Connected to the submission_status_changed signal from signals.py. + """ + sse_broadcaster.broadcast(payload) + logger.debug(f"Broadcast SSE event: {payload.get('submission_type')}#{payload.get('submission_id')}") + + +# Connect the signal handler +submission_status_changed.connect(handle_submission_status_changed) + + +class ModerationSSEView(APIView): + """ + Server-Sent Events endpoint for real-time moderation updates. + + Provides a streaming response that sends submission status changes + as they occur. Clients should connect to this endpoint and keep + the connection open to receive real-time updates. + + Response format (SSE): + data: {"submission_id": 1, "new_status": "CLAIMED", ...} + + Usage: + const eventSource = new EventSource('/api/moderation/sse/') + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data) + // Handle update + } + """ + + permission_classes = [IsAuthenticated, CanViewModerationData] + + def get(self, request): + """ + Establish SSE connection and stream events. + + Sends a heartbeat every 30 seconds to keep the connection alive. + """ + def event_stream() -> Generator[str, None, None]: + client_queue = sse_broadcaster.subscribe() + + try: + # Send initial connection event + yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established'})}\n\n" + + while True: + try: + # Wait for event with timeout for heartbeat + event = client_queue.get(timeout=30) + yield f"data: {json.dumps(event)}\n\n" + except queue.Empty: + # Send heartbeat to keep connection alive + yield f": heartbeat\n\n" + except GeneratorExit: + # Client disconnected + sse_broadcaster.unsubscribe(client_queue) + finally: + sse_broadcaster.unsubscribe(client_queue) + + response = StreamingHttpResponse( + event_stream(), + content_type='text/event-stream' + ) + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' # Disable nginx buffering + response['Connection'] = 'keep-alive' + + return response + + +class ModerationSSETestView(APIView): + """ + Test endpoint to manually trigger an SSE event. + + This is useful for testing the SSE connection without making + actual state transitions. + + POST /api/moderation/sse/test/ + { + "submission_id": 1, + "submission_type": "edit", + "new_status": "CLAIMED", + "previous_status": "PENDING" + } + """ + + permission_classes = [IsAuthenticated, CanViewModerationData] + + def post(self, request): + """Broadcast a test event.""" + test_payload = { + "submission_id": request.data.get("submission_id", 999), + "submission_type": request.data.get("submission_type", "edit"), + "new_status": request.data.get("new_status", "CLAIMED"), + "previous_status": request.data.get("previous_status", "PENDING"), + "locked_by": request.user.username, + "locked_at": None, + "changed_by": request.user.username, + "test": True, + } + + sse_broadcaster.broadcast(test_payload) + + return JsonResponse({ + "status": "ok", + "message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients", + "payload": test_payload, + }) + + +__all__ = [ + 'ModerationSSEView', + 'ModerationSSETestView', + 'sse_broadcaster', +] diff --git a/backend/apps/moderation/urls.py b/backend/apps/moderation/urls.py index e2fee8fd..7b78ea3f 100644 --- a/backend/apps/moderation/urls.py +++ b/backend/apps/moderation/urls.py @@ -16,7 +16,10 @@ from .views import ( ModerationActionViewSet, BulkOperationViewSet, UserModerationViewSet, + EditSubmissionViewSet, + PhotoSubmissionViewSet, ) +from .sse import ModerationSSEView, ModerationSSETestView from apps.core.views.views import FSMTransitionView @@ -68,9 +71,16 @@ router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue") router.register(r"actions", ModerationActionViewSet, basename="moderation-actions") router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations") router.register(r"users", UserModerationViewSet, basename="user-moderation") +# EditSubmission - register under both names for compatibility +router.register(r"submissions", EditSubmissionViewSet, basename="submissions") +router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions") +# PhotoSubmission - register under both names for compatibility +router.register(r"photos", PhotoSubmissionViewSet, basename="photos") +router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions") app_name = "moderation" + # FSM transition convenience URLs for moderation models fsm_transition_patterns = [ # EditSubmission transitions @@ -161,9 +171,17 @@ html_patterns = [ path("history/", HistoryPageView.as_view(), name="history"), ] +# SSE endpoints for real-time updates +sse_patterns = [ + path("sse/", ModerationSSEView.as_view(), name="moderation-sse"), + path("sse/test/", ModerationSSETestView.as_view(), name="moderation-sse-test"), +] + urlpatterns = [ # HTML page views *html_patterns, + # SSE endpoints + *sse_patterns, # Include all router URLs (API endpoints) path("api/", include(router.urls)), # FSM transition convenience endpoints diff --git a/backend/apps/moderation/views.py b/backend/apps/moderation/views.py index 469fb0fc..e66d713a 100644 --- a/backend/apps/moderation/views.py +++ b/backend/apps/moderation/views.py @@ -34,6 +34,8 @@ from .models import ( ModerationQueue, ModerationAction, BulkOperation, + EditSubmission, + PhotoSubmission, ) from .serializers import ( ModerationReportSerializer, @@ -47,6 +49,9 @@ from .serializers import ( BulkOperationSerializer, CreateBulkOperationSerializer, UserModerationProfileSerializer, + EditSubmissionSerializer, + EditSubmissionListSerializer, + PhotoSubmissionSerializer, ) from .filters import ( ModerationReportFilter, @@ -1166,6 +1171,28 @@ class UserModerationViewSet(viewsets.ViewSet): # Default serializer for schema generation serializer_class = UserModerationProfileSerializer + def list(self, request): + """Search for users to moderate.""" + query = request.query_params.get("q", "") + if not query: + return Response([]) + + queryset = User.objects.filter( + Q(username__icontains=query) | Q(email__icontains=query) + )[:20] + + users_data = [ + { + "id": user.id, + "username": user.username, + "email": user.email, + "role": getattr(user, "role", "USER"), + "is_active": user.is_active, + } + for user in queryset + ] + return Response(users_data) + def retrieve(self, request, pk=None): """Get moderation profile for a specific user.""" try: @@ -1367,3 +1394,345 @@ class UserModerationViewSet(viewsets.ViewSet): } return Response(stats_data) + + +# ============================================================================ +# Submission ViewSets +# ============================================================================ + + +class EditSubmissionViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing edit submissions. + + Includes claim/unclaim endpoints with concurrency protection using + database row locking (select_for_update) to prevent race conditions. + """ + queryset = EditSubmission.objects.all() + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + search_fields = ["reason", "changes"] + ordering_fields = ["created_at", "status"] + ordering = ["-created_at"] + permission_classes = [CanViewModerationData] + + def get_serializer_class(self): + if self.action == "list": + return EditSubmissionListSerializer + return EditSubmissionSerializer + + def get_queryset(self): + queryset = super().get_queryset() + status = self.request.query_params.get("status") + if status: + queryset = queryset.filter(status=status) + + # User filter + user_id = self.request.query_params.get("user") + if user_id: + queryset = queryset.filter(user_id=user_id) + + return queryset + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def claim(self, request, pk=None): + """ + Claim a submission for review with concurrency protection. + + Uses select_for_update() to acquire a database row lock, + preventing race conditions when multiple moderators try to + claim the same submission simultaneously. + + Returns: + 200: Submission successfully claimed + 404: Submission not found + 409: Submission already claimed or being claimed by another moderator + 400: Invalid state for claiming + """ + from django.db import transaction, DatabaseError + from django.core.exceptions import ValidationError + + with transaction.atomic(): + try: + # Lock the row for update - other transactions will fail immediately + submission = EditSubmission.objects.select_for_update(nowait=True).get(pk=pk) + except EditSubmission.DoesNotExist: + return Response( + {"error": "Submission not found"}, + status=status.HTTP_404_NOT_FOUND + ) + except DatabaseError: + # Row is already locked by another transaction + return Response( + {"error": "Submission is being claimed by another moderator. Please try again."}, + status=status.HTTP_409_CONFLICT + ) + + # Check if already claimed + if submission.status == "CLAIMED": + return Response( + { + "error": "Submission already claimed", + "claimed_by": submission.claimed_by.username if submission.claimed_by else None, + "claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None, + }, + status=status.HTTP_409_CONFLICT + ) + + # Check if in valid state for claiming + if submission.status != "PENDING": + return Response( + {"error": f"Cannot claim submission in {submission.status} state"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + submission.claim(user=request.user) + log_business_event( + logger, + event_type="submission_claimed", + message=f"EditSubmission {submission.id} claimed by {request.user.username}", + context={ + "model": "EditSubmission", + "object_id": submission.id, + "claimed_by": request.user.username, + }, + request=request, + ) + return Response(self.get_serializer(submission).data) + except ValidationError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def unclaim(self, request, pk=None): + """ + Release claim on a submission. + + Only the claiming moderator or an admin can unclaim a submission. + """ + from django.core.exceptions import ValidationError + + submission = self.get_object() + + # Only the claiming user or an admin can unclaim + if submission.claimed_by != request.user and not request.user.is_staff: + return Response( + {"error": "Only the claiming moderator or an admin can unclaim"}, + status=status.HTTP_403_FORBIDDEN + ) + + if submission.status != "CLAIMED": + return Response( + {"error": "Submission is not claimed"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + submission.unclaim(user=request.user) + log_business_event( + logger, + event_type="submission_unclaimed", + message=f"EditSubmission {submission.id} unclaimed by {request.user.username}", + context={ + "model": "EditSubmission", + "object_id": submission.id, + "unclaimed_by": request.user.username, + }, + request=request, + ) + return Response(self.get_serializer(submission).data) + except ValidationError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def approve(self, request, pk=None): + submission = self.get_object() + user = request.user + + try: + submission.approve(moderator=user) + return Response(self.get_serializer(submission).data) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def reject(self, request, pk=None): + submission = self.get_object() + user = request.user + reason = request.data.get("reason", "") + + try: + submission.reject(moderator=user, reason=reason) + return Response(self.get_serializer(submission).data) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def escalate(self, request, pk=None): + submission = self.get_object() + user = request.user + reason = request.data.get("reason", "") + + try: + submission.escalate(moderator=user, reason=reason) + return Response(self.get_serializer(submission).data) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class PhotoSubmissionViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing photo submissions. + + Includes claim/unclaim endpoints with concurrency protection using + database row locking (select_for_update) to prevent race conditions. + """ + queryset = PhotoSubmission.objects.all() + serializer_class = PhotoSubmissionSerializer + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + search_fields = ["caption", "notes"] + ordering_fields = ["created_at", "status"] + ordering = ["-created_at"] + permission_classes = [CanViewModerationData] + + def get_queryset(self): + queryset = super().get_queryset() + status = self.request.query_params.get("status") + if status: + queryset = queryset.filter(status=status) + + # User filter + user_id = self.request.query_params.get("user") + if user_id: + queryset = queryset.filter(user_id=user_id) + + return queryset + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def claim(self, request, pk=None): + """ + Claim a photo submission for review with concurrency protection. + + Uses select_for_update() to acquire a database row lock. + """ + from django.db import transaction, DatabaseError + from django.core.exceptions import ValidationError + + with transaction.atomic(): + try: + submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk) + except PhotoSubmission.DoesNotExist: + return Response( + {"error": "Submission not found"}, + status=status.HTTP_404_NOT_FOUND + ) + except DatabaseError: + return Response( + {"error": "Submission is being claimed by another moderator. Please try again."}, + status=status.HTTP_409_CONFLICT + ) + + if submission.status == "CLAIMED": + return Response( + { + "error": "Submission already claimed", + "claimed_by": submission.claimed_by.username if submission.claimed_by else None, + "claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None, + }, + status=status.HTTP_409_CONFLICT + ) + + if submission.status != "PENDING": + return Response( + {"error": f"Cannot claim submission in {submission.status} state"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + submission.claim(user=request.user) + log_business_event( + logger, + event_type="submission_claimed", + message=f"PhotoSubmission {submission.id} claimed by {request.user.username}", + context={ + "model": "PhotoSubmission", + "object_id": submission.id, + "claimed_by": request.user.username, + }, + request=request, + ) + return Response(self.get_serializer(submission).data) + except ValidationError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def unclaim(self, request, pk=None): + """Release claim on a photo submission.""" + from django.core.exceptions import ValidationError + + submission = self.get_object() + + if submission.claimed_by != request.user and not request.user.is_staff: + return Response( + {"error": "Only the claiming moderator or an admin can unclaim"}, + status=status.HTTP_403_FORBIDDEN + ) + + if submission.status != "CLAIMED": + return Response( + {"error": "Submission is not claimed"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + submission.unclaim(user=request.user) + log_business_event( + logger, + event_type="submission_unclaimed", + message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}", + context={ + "model": "PhotoSubmission", + "object_id": submission.id, + "unclaimed_by": request.user.username, + }, + request=request, + ) + return Response(self.get_serializer(submission).data) + except ValidationError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def approve(self, request, pk=None): + submission = self.get_object() + user = request.user + notes = request.data.get("notes", "") + + try: + submission.approve(moderator=user, notes=notes) + return Response(self.get_serializer(submission).data) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def reject(self, request, pk=None): + submission = self.get_object() + user = request.user + notes = request.data.get("notes", "") + + try: + submission.reject(moderator=user, notes=notes) + return Response(self.get_serializer(submission).data) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin]) + def escalate(self, request, pk=None): + submission = self.get_object() + user = request.user + notes = request.data.get("notes", "") + + try: + submission.escalate(moderator=user, notes=notes) + return Response(self.get_serializer(submission).data) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + diff --git a/backend/apps/reviews/apps.py b/backend/apps/reviews/apps.py index f21f5477..8eb3cfd3 100644 --- a/backend/apps/reviews/apps.py +++ b/backend/apps/reviews/apps.py @@ -4,3 +4,6 @@ class ReviewsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.reviews" verbose_name = "User Reviews" + + def ready(self): + import apps.reviews.signals diff --git a/backend/apps/reviews/migrations/0001_initial.py b/backend/apps/reviews/migrations/0001_initial.py new file mode 100644 index 00000000..7b924517 --- /dev/null +++ b/backend/apps/reviews/migrations/0001_initial.py @@ -0,0 +1,176 @@ +# Generated by Django 5.1.6 on 2025-12-26 14:29 + +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("pghistory", "0006_delete_aggregateevent"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Review", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("object_id", models.PositiveIntegerField(help_text="ID of the item being reviewed")), + ( + "rating", + models.PositiveSmallIntegerField( + choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")], + db_index=True, + help_text="Rating from 1 to 5", + ), + ), + ("text", models.TextField(blank=True, help_text="Review text (optional)")), + ("is_public", models.BooleanField(default=True, help_text="Whether this review is visible to others")), + ( + "helpful_votes", + models.PositiveIntegerField(default=0, help_text="Number of users who found this helpful"), + ), + ( + "content_type", + models.ForeignKey( + help_text="Type of item being reviewed", + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "user", + models.ForeignKey( + help_text="User who wrote the review", + on_delete=django.db.models.deletion.CASCADE, + related_name="reviews", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Review", + "verbose_name_plural": "Reviews", + "ordering": ["-created_at"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="ReviewEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.BigIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("object_id", models.PositiveIntegerField(help_text="ID of the item being reviewed")), + ( + "rating", + models.PositiveSmallIntegerField( + choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")], help_text="Rating from 1 to 5" + ), + ), + ("text", models.TextField(blank=True, help_text="Review text (optional)")), + ("is_public", models.BooleanField(default=True, help_text="Whether this review is visible to others")), + ( + "helpful_votes", + models.PositiveIntegerField(default=0, help_text="Number of users who found this helpful"), + ), + ( + "content_type", + models.ForeignKey( + db_constraint=False, + help_text="Type of item being reviewed", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="contenttypes.contenttype", + ), + ), + ( + "pgh_context", + models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="pghistory.context", + ), + ), + ( + "pgh_obj", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="events", + to="reviews.review", + ), + ), + ( + "user", + models.ForeignKey( + db_constraint=False, + help_text="User who wrote the review", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="review", + index=models.Index(fields=["content_type", "object_id"], name="reviews_rev_content_627d80_idx"), + ), + migrations.AddIndex( + model_name="review", + index=models.Index(fields=["rating"], name="reviews_rev_rating_2db6dd_idx"), + ), + migrations.AlterUniqueTogether( + name="review", + unique_together={("user", "content_type", "object_id")}, + ), + pgtrigger.migrations.AddTrigger( + model_name="review", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "reviews_reviewevent" ("content_type_id", "created_at", "helpful_votes", "id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "text", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."helpful_votes", NEW."id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."text", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="72f23486e0f1db9f6f47e7cd42888c4d87a6a31b", + operation="INSERT", + pgid="pgtrigger_insert_insert_7a7c1", + table="reviews_review", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="review", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "reviews_reviewevent" ("content_type_id", "created_at", "helpful_votes", "id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "text", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."helpful_votes", NEW."id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."text", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="ca02efb281912450d6755ec9b07ebc998eabf421", + operation="UPDATE", + pgid="pgtrigger_update_update_b34c8", + table="reviews_review", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/reviews/migrations/__init__.py b/backend/apps/reviews/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/reviews/serializers.py b/backend/apps/reviews/serializers.py new file mode 100644 index 00000000..7ba629f5 --- /dev/null +++ b/backend/apps/reviews/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers +from .models import Review +from apps.accounts.serializers import UserSerializer + +class ReviewSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + + class Meta: + model = Review + fields = [ + "id", + "user", + "content_type", + "object_id", + "rating", + "text", + "is_public", + "helpful_votes", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "user", "helpful_votes", "created_at", "updated_at"] + + def validate(self, data): + """ + Check that rating is between 1 and 5. + """ + # Rating is already validated by model field validation but explicit check is good + return data diff --git a/backend/apps/reviews/signals.py b/backend/apps/reviews/signals.py new file mode 100644 index 00000000..9a926d80 --- /dev/null +++ b/backend/apps/reviews/signals.py @@ -0,0 +1,30 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from django.db.models import Avg +from .models import Review + +@receiver(post_save, sender=Review) +@receiver(post_delete, sender=Review) +def update_average_rating(sender, instance, **kwargs): + """ + Update the average rating of the content object when a review is saved or deleted. + """ + content_object = instance.content_object + if not content_object: + # If content object doesn't exist (orphaned review?), skip + return + + # Check if the content object has an 'average_rating' field + if not hasattr(content_object, 'average_rating'): + return + + # Calculate new average + # We query the Review model filtering by content_type and object_id + avg_rating = Review.objects.filter( + content_type=instance.content_type, + object_id=instance.object_id + ).aggregate(Avg('rating'))['rating__avg'] + + # Update field + content_object.average_rating = avg_rating or 0 # Default to 0 if no reviews + content_object.save(update_fields=['average_rating']) diff --git a/backend/apps/reviews/urls.py b/backend/apps/reviews/urls.py new file mode 100644 index 00000000..e80a56f1 --- /dev/null +++ b/backend/apps/reviews/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ReviewViewSet + +router = DefaultRouter() +router.register(r"reviews", ReviewViewSet, basename="review") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/reviews/views.py b/backend/apps/reviews/views.py new file mode 100644 index 00000000..d2dc48b3 --- /dev/null +++ b/backend/apps/reviews/views.py @@ -0,0 +1,27 @@ +from rest_framework import viewsets, permissions, filters +from django_filters.rest_framework import DjangoFilterBackend +from .models import Review +from .serializers import ReviewSerializer +from apps.core.permissions import IsOwnerOrReadOnly + +class ReviewViewSet(viewsets.ModelViewSet): + queryset = Review.objects.filter(is_public=True) + serializer_class = ReviewSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["content_type", "object_id", "rating", "user"] + ordering_fields = ["created_at", "rating", "helpful_votes"] + ordering = ["-created_at"] + + def get_queryset(self): + # Users can see their own non-public reviews? + # Standard queryset is public only. + # But if we want authors to see their own pending/private reviews: + qs = Review.objects.filter(is_public=True) + if self.request.user.is_authenticated: + # Add user's own reviews even if not public (if that's a use case) + qs = qs | Review.objects.filter(user=self.request.user) + return qs.distinct() + + def perform_create(self, serializer): + serializer.save(user=self.request.user) diff --git a/backend/apps/rides/migrations/0028_ridecredit_ridecreditevent_ridecredit_insert_insert_and_more.py b/backend/apps/rides/migrations/0028_ridecredit_ridecreditevent_ridecredit_insert_insert_and_more.py new file mode 100644 index 00000000..8e1e7cb7 --- /dev/null +++ b/backend/apps/rides/migrations/0028_ridecredit_ridecreditevent_ridecredit_insert_insert_and_more.py @@ -0,0 +1,169 @@ +# Generated by Django 5.1.6 on 2025-12-26 15:57 + +import django.core.validators +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pghistory", "0006_delete_aggregateevent"), + ("rides", "0027_alter_company_options_alter_rankingsnapshot_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RideCredit", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("count", models.PositiveIntegerField(default=1, help_text="Number of times ridden")), + ( + "rating", + models.IntegerField( + blank=True, + help_text="Personal rating (1-5)", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(5), + ], + ), + ), + ("first_ridden_at", models.DateField(blank=True, help_text="Date of first ride", null=True)), + ("last_ridden_at", models.DateField(blank=True, help_text="Date of most recent ride", null=True)), + ("notes", models.TextField(blank=True, help_text="Personal notes about the experience")), + ( + "ride", + models.ForeignKey( + help_text="The ride that was ridden", + on_delete=django.db.models.deletion.CASCADE, + related_name="credits", + to="rides.ride", + ), + ), + ( + "user", + models.ForeignKey( + help_text="User who rode the ride", + on_delete=django.db.models.deletion.CASCADE, + related_name="ride_credits", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Ride Credit", + "verbose_name_plural": "Ride Credits", + "ordering": ["-last_ridden_at", "-first_ridden_at", "-created_at"], + "abstract": False, + "unique_together": {("user", "ride")}, + }, + ), + migrations.CreateModel( + name="RideCreditEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.BigIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("count", models.PositiveIntegerField(default=1, help_text="Number of times ridden")), + ( + "rating", + models.IntegerField( + blank=True, + help_text="Personal rating (1-5)", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(5), + ], + ), + ), + ("first_ridden_at", models.DateField(blank=True, help_text="Date of first ride", null=True)), + ("last_ridden_at", models.DateField(blank=True, help_text="Date of most recent ride", null=True)), + ("notes", models.TextField(blank=True, help_text="Personal notes about the experience")), + ( + "pgh_context", + models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="pghistory.context", + ), + ), + ( + "pgh_obj", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="events", + to="rides.ridecredit", + ), + ), + ( + "ride", + models.ForeignKey( + db_constraint=False, + help_text="The ride that was ridden", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to="rides.ride", + ), + ), + ( + "user", + models.ForeignKey( + db_constraint=False, + help_text="User who rode the ride", + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + related_query_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + pgtrigger.migrations.AddTrigger( + model_name="ridecredit", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="99d9f7e7134fbcb6f84a1966fe9539c8ccc22eee", + operation="INSERT", + pgid="pgtrigger_insert_insert_00439", + table="rides_ridecredit", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ridecredit", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;', + hash="1795a528b3b188da59c8cf60053df8eddb80904c", + operation="UPDATE", + pgid="pgtrigger_update_update_32a65", + table="rides_ridecredit", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/rides/models/__init__.py b/backend/apps/rides/models/__init__.py index e298dd98..5a260cd2 100644 --- a/backend/apps/rides/models/__init__.py +++ b/backend/apps/rides/models/__init__.py @@ -14,6 +14,7 @@ from .location import RideLocation from .reviews import RideReview from .rankings import RideRanking, RidePairComparison, RankingSnapshot from .media import RidePhoto +from .credits import RideCredit __all__ = [ # Primary models @@ -24,6 +25,7 @@ __all__ = [ "RideLocation", "RideReview", "RidePhoto", + "RideCredit", # Rankings "RideRanking", "RidePairComparison", diff --git a/backend/apps/rides/models/credits.py b/backend/apps/rides/models/credits.py new file mode 100644 index 00000000..45275dad --- /dev/null +++ b/backend/apps/rides/models/credits.py @@ -0,0 +1,55 @@ +from django.db import models +from django.conf import settings +from django.core.validators import MinValueValidator, MaxValueValidator +import pghistory + +from apps.core.history import TrackedModel + +@pghistory.track() +class RideCredit(TrackedModel): + """ + Represents a user's ride credit (a ride they have ridden). + Functions as a through-model for tracking which rides a user has experienced. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="ride_credits", + help_text="User who rode the ride", + ) + ride = models.ForeignKey( + "rides.Ride", + on_delete=models.CASCADE, + related_name="credits", + help_text="The ride that was ridden", + ) + + # Credit Details + count = models.PositiveIntegerField( + default=1, help_text="Number of times ridden" + ) + rating = models.IntegerField( + null=True, + blank=True, + validators=[MinValueValidator(1), MaxValueValidator(5)], + help_text="Personal rating (1-5)", + ) + first_ridden_at = models.DateField( + null=True, blank=True, help_text="Date of first ride" + ) + last_ridden_at = models.DateField( + null=True, blank=True, help_text="Date of most recent ride" + ) + notes = models.TextField( + blank=True, help_text="Personal notes about the experience" + ) + + class Meta(TrackedModel.Meta): + verbose_name = "Ride Credit" + verbose_name_plural = "Ride Credits" + unique_together = ["user", "ride"] + ordering = ["-last_ridden_at", "-first_ridden_at", "-created_at"] + + def __str__(self): + return f"{self.user} - {self.ride}" diff --git a/backend/apps/support/__init__.py b/backend/apps/support/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/support/apps.py b/backend/apps/support/apps.py new file mode 100644 index 00000000..7f023d02 --- /dev/null +++ b/backend/apps/support/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class SupportConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.support" + verbose_name = "Support" diff --git a/backend/apps/support/migrations/0001_initial.py b/backend/apps/support/migrations/0001_initial.py new file mode 100644 index 00000000..e66f5f40 --- /dev/null +++ b/backend/apps/support/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.6 on 2025-12-26 14:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Ticket", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("subject", models.CharField(max_length=255)), + ("message", models.TextField()), + ("email", models.EmailField(blank=True, help_text="Contact email", max_length=254)), + ( + "status", + models.CharField( + choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")], + db_index=True, + default="open", + max_length=20, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + help_text="User who submitted the ticket (optional)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Ticket", + "verbose_name_plural": "Tickets", + "ordering": ["-created_at"], + "abstract": False, + }, + ), + ] diff --git a/backend/apps/support/migrations/__init__.py b/backend/apps/support/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/support/models.py b/backend/apps/support/models.py new file mode 100644 index 00000000..d7fb39a7 --- /dev/null +++ b/backend/apps/support/models.py @@ -0,0 +1,48 @@ +from django.db import models +from django.conf import settings +from apps.core.history import TrackedModel + +class Ticket(TrackedModel): + STATUS_OPEN = 'open' + STATUS_IN_PROGRESS = 'in_progress' + STATUS_CLOSED = 'closed' + + STATUS_CHOICES = [ + (STATUS_OPEN, 'Open'), + (STATUS_IN_PROGRESS, 'In Progress'), + (STATUS_CLOSED, 'Closed'), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="tickets", + help_text="User who submitted the ticket (optional)" + ) + + subject = models.CharField(max_length=255) + message = models.TextField() + email = models.EmailField(help_text="Contact email", blank=True) + + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default=STATUS_OPEN, + db_index=True + ) + + class Meta(TrackedModel.Meta): + verbose_name = "Ticket" + verbose_name_plural = "Tickets" + ordering = ["-created_at"] + + def __str__(self): + return f"[{self.get_status_display()}] {self.subject}" + + def save(self, *args, **kwargs): + # If user is set but email is empty, autofill from user + if self.user and not self.email: + self.email = self.user.email + super().save(*args, **kwargs) diff --git a/backend/apps/support/serializers.py b/backend/apps/support/serializers.py new file mode 100644 index 00000000..6e49c9a6 --- /dev/null +++ b/backend/apps/support/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers +from .models import Ticket +from apps.accounts.serializers import UserSerializer + +class TicketSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + + class Meta: + model = Ticket + fields = [ + "id", + "user", + "subject", + "message", + "email", + "status", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "status", "created_at", "updated_at", "user"] + + def validate(self, data): + # Ensure email is provided if user is anonymous + request = self.context.get('request') + if request and not request.user.is_authenticated and not data.get('email'): + raise serializers.ValidationError({"email": "Email is required for guests."}) + return data diff --git a/backend/apps/support/urls.py b/backend/apps/support/urls.py new file mode 100644 index 00000000..ed98dbd0 --- /dev/null +++ b/backend/apps/support/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import TicketViewSet + +router = DefaultRouter() +router.register(r"tickets", TicketViewSet, basename="ticket") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/support/views.py b/backend/apps/support/views.py new file mode 100644 index 00000000..8ac6c293 --- /dev/null +++ b/backend/apps/support/views.py @@ -0,0 +1,32 @@ +from rest_framework import viewsets, permissions, filters +from django_filters.rest_framework import DjangoFilterBackend +from .models import Ticket +from .serializers import TicketSerializer + +class TicketViewSet(viewsets.ModelViewSet): + """ + Standard users/guests can CREATE. + Only Staff can LIST/RETRIEVE/UPDATE all. + Users can LIST/RETRIEVE their own. + """ + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + permission_classes = [permissions.AllowAny] # We handle granular perms in get_queryset/perform_create + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["status"] + ordering_fields = ["created_at", "status"] + ordering = ["-created_at"] + + def get_queryset(self): + user = self.request.user + if user.is_staff: + return Ticket.objects.all() + if user.is_authenticated: + return Ticket.objects.filter(user=user) + return Ticket.objects.none() # Guests can't list tickets + + def perform_create(self, serializer): + if self.request.user.is_authenticated: + serializer.save(user=self.request.user, email=self.request.user.email) + else: + serializer.save() diff --git a/backend/config/django/base.py b/backend/config/django/base.py index ed9e76e3..43abe928 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -114,6 +114,10 @@ LOCAL_APPS = [ "django_forwardemail", # New PyPI package for email service "apps.moderation", "apps.lists", + "apps.reviews", + "apps.media", + "apps.blog", + "apps.support", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/backend/ensure_admin.py b/backend/ensure_admin.py new file mode 100644 index 00000000..c628c880 --- /dev/null +++ b/backend/ensure_admin.py @@ -0,0 +1,32 @@ +import os +import sys +import django + +sys.path.append(os.path.join(os.path.dirname(__file__))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings") +django.setup() + +from django.contrib.auth import get_user_model +User = get_user_model() + +def ensure_admin(): + username = "admin" + email = "admin@example.com" + password = "adminpassword" + + if not User.objects.filter(username=username).exists(): + print(f"Creating superuser {username}...") + User.objects.create_superuser(username=username, email=email, password=password, role="ADMIN") + print("Superuser created.") + else: + print(f"Superuser {username} already exists.") + u = User.objects.get(username=username) + if not u.is_staff or not u.is_superuser or u.role != 'ADMIN': + u.is_staff = True + u.is_superuser = True + u.role = 'ADMIN' + u.save() + print("Updated existing user to ADMIN/Superuser.") + +if __name__ == "__main__": + ensure_admin() diff --git a/backend/tests/serializers/test_account_serializers.py b/backend/tests/serializers/test_account_serializers.py index eb56f603..e24d0f2b 100644 --- a/backend/tests/serializers/test_account_serializers.py +++ b/backend/tests/serializers/test_account_serializers.py @@ -21,12 +21,7 @@ from apps.api.v1.accounts.serializers import ( UserProfileCreateInputSerializer, UserProfileUpdateInputSerializer, UserProfileOutputSerializer, - TopListCreateInputSerializer, - TopListUpdateInputSerializer, - TopListOutputSerializer, - TopListItemCreateInputSerializer, - TopListItemUpdateInputSerializer, - TopListItemOutputSerializer, + ) from tests.factories import ( @@ -480,35 +475,4 @@ class TestUserProfileUpdateInputSerializer(TestCase): assert extra_kwargs.get("user", {}).get("read_only") is True -class TestTopListCreateInputSerializer(TestCase): - """Tests for TopListCreateInputSerializer.""" - def test__meta__fields__includes_all_fields(self): - """Test Meta.fields is set to __all__.""" - assert TopListCreateInputSerializer.Meta.fields == "__all__" - - -class TestTopListUpdateInputSerializer(TestCase): - """Tests for TopListUpdateInputSerializer.""" - - def test__meta__user_read_only(self): - """Test user field is read-only for updates.""" - extra_kwargs = TopListUpdateInputSerializer.Meta.extra_kwargs - assert extra_kwargs.get("user", {}).get("read_only") is True - - -class TestTopListItemCreateInputSerializer(TestCase): - """Tests for TopListItemCreateInputSerializer.""" - - def test__meta__fields__includes_all_fields(self): - """Test Meta.fields is set to __all__.""" - assert TopListItemCreateInputSerializer.Meta.fields == "__all__" - - -class TestTopListItemUpdateInputSerializer(TestCase): - """Tests for TopListItemUpdateInputSerializer.""" - - def test__meta__top_list_not_read_only(self): - """Test top_list field is not read-only for updates.""" - extra_kwargs = TopListItemUpdateInputSerializer.Meta.extra_kwargs - assert extra_kwargs.get("top_list", {}).get("read_only") is False diff --git a/source_docs/COMPONENTS.md b/source_docs/COMPONENTS.md new file mode 100644 index 00000000..f4d59786 --- /dev/null +++ b/source_docs/COMPONENTS.md @@ -0,0 +1,966 @@ +# ThrillWiki Component Library + +> Complete UI component documentation + +--- + +## Table of Contents + +1. [Layout Components](#layout-components) +2. [Navigation Components](#navigation-components) +3. [Display Components](#display-components) +4. [Form Components](#form-components) +5. [Feedback Components](#feedback-components) +6. [Data Display](#data-display) +7. [Entity Components](#entity-components) +8. [Specialty Components](#specialty-components) + +--- + +## Component Architecture + +``` +Components +β”œβ”€β”€ Layout +β”‚ β”œβ”€β”€ Header +β”‚ β”œβ”€β”€ Footer +β”‚ β”œβ”€β”€ Sidebar +β”‚ └── PageContainer +β”‚ +β”œβ”€β”€ Navigation +β”‚ β”œβ”€β”€ MainNav +β”‚ β”œβ”€β”€ TabNav +β”‚ β”œβ”€β”€ Breadcrumbs +β”‚ └── Pagination +β”‚ +β”œβ”€β”€ Display +β”‚ β”œβ”€β”€ Card +β”‚ β”œβ”€β”€ Badge +β”‚ β”œβ”€β”€ Avatar +β”‚ └── Image +β”‚ +β”œβ”€β”€ Forms +β”‚ β”œβ”€β”€ Input +β”‚ β”œβ”€β”€ Select +β”‚ β”œβ”€β”€ Checkbox +β”‚ └── Button +β”‚ +β”œβ”€β”€ Feedback +β”‚ β”œβ”€β”€ Toast +β”‚ β”œβ”€β”€ Alert +β”‚ β”œβ”€β”€ Modal +β”‚ └── Loading +β”‚ +β”œβ”€β”€ Data +β”‚ β”œβ”€β”€ Table +β”‚ β”œβ”€β”€ List +β”‚ β”œβ”€β”€ Stats +β”‚ └── Charts +β”‚ +└── Entity + β”œβ”€β”€ ParkCard + β”œβ”€β”€ RideCard + β”œβ”€β”€ ReviewCard + └── CreditCard +``` + +--- + +## Layout Components + +### Header + +The main navigation bar at the top of every page. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 🎒 ThrillWiki Parks Rides Companies πŸ” πŸ‘€ πŸ”” β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Logo/brand link to homepage +- Primary navigation links +- Search button/input +- User menu (avatar dropdown) +- Notification bell +- Mobile hamburger menu + +**Variants:** +- Default: Full navigation +- Minimal: Logo only (auth pages) +- Admin: Additional admin links + +--- + +### Footer + +Site-wide footer with links and information. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ ThrillWiki Explore Community Legal β”‚ +β”‚ The ultimate Parks Guidelines Terms β”‚ +β”‚ theme park Rides Leaderboard Privacy β”‚ +β”‚ database Companies Blog Contact β”‚ +β”‚ Models Discord β”‚ +β”‚ β”‚ +β”‚ ───────────────────────────────────────────────────────────── β”‚ +β”‚ Β© 2024 ThrillWiki [Twitter] [Discord] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Sections:** +- Brand info +- Navigation columns +- Social links +- Copyright + +--- + +### PageContainer + +Wrapper component for consistent page layout. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ HEADER β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ PAGE CONTENT β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ max-width: 1280px β”‚ β”‚ +β”‚ β”‚ padding: responsive β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ FOOTER β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Props:** +| Prop | Type | Description | +|------|------|-------------| +| `maxWidth` | string | Container max width | +| `padding` | string | Content padding | +| `className` | string | Additional classes | + +--- + +### Sidebar + +Collapsible sidebar for settings and admin pages. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Section 1 β”‚ +β”‚ β”œβ”€β”€ Item 1 β”‚ +β”‚ β”œβ”€β”€ Item 2 ● β”‚ ← Active +β”‚ └── Item 3 β”‚ +β”‚ β”‚ +β”‚ Section 2 β”‚ +β”‚ β”œβ”€β”€ Item 4 β”‚ +β”‚ └── Item 5 β”‚ +β”‚ β”‚ +β”‚ [Collapse β—€] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Navigation Components + +### MainNav + +Primary navigation in header. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Parks β–Ύ Rides β–Ύ Companies β–Ύ More β–Ύ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ All Parks β”‚ +β”‚ Parks Nearby β”‚ +β”‚ ───────────── β”‚ +β”‚ Add New Park β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Dropdown menus on hover/click +- Mobile drawer navigation +- Active state highlighting +- Keyboard navigation + +--- + +### TabNav + +Horizontal tab navigation within pages. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Overview Rides Reviews Photos History β”‚ +β”‚ ═════════ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Props:** +| Prop | Type | Description | +|------|------|-------------| +| `tabs` | Tab[] | Array of tab definitions | +| `activeTab` | string | Currently active tab | +| `onChange` | function | Tab change handler | + +--- + +### Breadcrumbs + +Hierarchical navigation path. + +``` +Home > Parks > Cedar Point > Steel Vengeance +``` + +**Features:** +- Clickable ancestors +- Current page (non-clickable) +- Truncation for long paths +- Schema.org markup + +--- + +### Pagination + +Navigate through paginated content. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β—€ Previous 1 2 [3] 4 5 ... 12 Next β–Ά β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Props:** +| Prop | Type | Description | +|------|------|-------------| +| `currentPage` | number | Current page | +| `totalPages` | number | Total page count | +| `onPageChange` | function | Page change handler | + +--- + +## Display Components + +### Card + +Versatile container for content. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ IMAGE β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Title β”‚ +β”‚ Subtitle or description β”‚ +β”‚ β”‚ +β”‚ [Action 1] [Action 2] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Variants:** +| Variant | Description | +|---------|-------------| +| Default | Basic card with border | +| Elevated | Shadow for depth | +| Interactive | Hover effects, clickable | +| Glass | Frosted glass (dark mode) | + +--- + +### Badge + +Small label for status or categories. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Operating β”‚ β”‚ Coaster β”‚ β”‚ New! β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + (green) (blue) (accent) +``` + +**Variants:** +| Variant | Usage | +|---------|-------| +| Default | General labels | +| Success | Operating, approved | +| Warning | Under construction | +| Destructive | Closed, error | +| Outline | Secondary importance | + +--- + +### Avatar + +User or entity image. + +``` +β”Œβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β” +β”‚ β”‚ β”‚ β”‚ β”‚ AB β”‚ +β”‚ πŸ‘€ β”‚ β”‚ IMG β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ + Icon Image Initials +``` + +**Sizes:** +- `sm`: 24px +- `md`: 40px (default) +- `lg`: 64px +- `xl`: 96px + +--- + +### Image + +Optimized image display with loading states. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ Loading... β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ Loaded Image β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Lazy loading +- Blur placeholder +- Error fallback +- Responsive sizes +- CloudFlare Images integration + +--- + +## Form Components + +### Input + +Text input field. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Label β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Placeholder text... β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + Hint text or validation message +``` + +**States:** +| State | Appearance | +|-------|------------| +| Default | Standard border | +| Focused | Primary ring | +| Error | Red border + message | +| Disabled | Muted, non-interactive | + +**Types:** +- Text, Email, Password, Number +- Search (with icon) +- Textarea (multiline) + +--- + +### Select + +Dropdown selection. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Select an option... β–Ύ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Option 1 β”‚ +β”‚ Option 2 βœ“ β”‚ +β”‚ Option 3 β”‚ +β”‚ ─────────────────────────── β”‚ +β”‚ Group Header β”‚ +β”‚ Option 4 β”‚ +β”‚ Option 5 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Searchable options +- Option groups +- Multi-select variant +- Clear selection + +--- + +### Checkbox + +Boolean or multi-selection. + +``` +☐ Unchecked option +β˜‘ Checked option +☐ Unchecked option +``` + +**Variants:** +- Single checkbox +- Checkbox group +- Indeterminate state + +--- + +### Radio + +Single selection from options. + +``` +β—‹ Option 1 +● Option 2 (selected) +β—‹ Option 3 +``` + +--- + +### Switch + +Toggle between two states. + +``` +Off ○──────● On + ●──────○ +``` + +--- + +### Button + +Clickable action trigger. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Primary β”‚ β”‚ Secondary β”‚ β”‚ Outline β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Ghost β”‚ β”‚ Destructive β”‚ β”‚ Loading β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Variants:** +| Variant | Usage | +|---------|-------| +| Primary | Main actions | +| Secondary | Secondary actions | +| Outline | Bordered, less emphasis | +| Ghost | Minimal, icon buttons | +| Destructive | Delete, dangerous | +| Link | Text-only, inline | + +**Sizes:** +- `sm`: Compact +- `default`: Standard +- `lg`: Large +- `icon`: Square, icon-only + +--- + +### DatePicker + +Date selection component. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ“… Select date... β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β—€ December 2024 β–Ά β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Su Mo Tu We Th Fr Sa β”‚ +β”‚ 1 2 3 4 5 6 7 β”‚ +β”‚ 8 9 10 11 12 13 14 β”‚ +β”‚ 15 16 17 18 19 [20] 21 β”‚ +β”‚ 22 23 24 25 26 27 28 β”‚ +β”‚ 29 30 31 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Month/year navigation +- Date range selection +- Disabled dates +- Date precision selector + +--- + +### Slider + +Range value selection. + +``` + 0 100 + ●───────────────────●────────────────────────────● + 50 +``` + +**Props:** +| Prop | Type | Description | +|------|------|-------------| +| `min` | number | Minimum value | +| `max` | number | Maximum value | +| `step` | number | Value increment | +| `value` | number | Current value | + +--- + +## Feedback Components + +### Toast + +Temporary notification message. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ βœ“ Changes saved successfully βœ• β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Variants:** +| Variant | Usage | +|---------|-------| +| Default | General info | +| Success | Positive confirmation | +| Warning | Caution notice | +| Error | Error occurred | + +--- + +### Alert + +Inline notification. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ⚠️ Warning β”‚ +β”‚ This action cannot be undone. β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Variants:** +- Info, Success, Warning, Error + +--- + +### Modal / Dialog + +Overlay for focused content. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Dialog Title βœ• β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Dialog content goes here β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ [Cancel] [Confirm] β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Types:** +- Alert Dialog (confirmation) +- Form Dialog (input) +- Info Dialog (display) +- Full-screen Dialog + +--- + +### Loading States + +Various loading indicators. + +**Spinner:** +``` + β— β—‘β—  +``` + +**Skeleton:** +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ +β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ +β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Progress:** +``` +[β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 60% +``` + +--- + +### Empty State + +No content placeholder. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ 🎒 β”‚ +β”‚ β”‚ +β”‚ No rides found β”‚ +β”‚ Try adjusting your filters β”‚ +β”‚ β”‚ +β”‚ [Clear Filters] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Data Display + +### Table + +Tabular data display. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Name β”‚ Type β”‚ Status β”‚ Actions β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Cedar Point β”‚ Theme Park β”‚ Operating β”‚ [View] β”‚ +β”‚ Six Flags β”‚ Theme Park β”‚ Operating β”‚ [View] β”‚ +β”‚ Dreamworld β”‚ Theme Park β”‚ Operating β”‚ [View] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Sortable columns +- Row selection +- Pagination +- Filtering +- Column resizing + +--- + +### Stats Card + +Metric display. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Total Parks β”‚ +β”‚ ──────────── β”‚ +β”‚ 1,234 β”‚ +β”‚ ↑ 12% from last month β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +### Rating Display + +Star rating visualization. + +``` +β˜…β˜…β˜…β˜…β˜† 4.2 (156 reviews) +``` + +**Props:** +| Prop | Type | Description | +|------|------|-------------| +| `rating` | number | 0-5 rating value | +| `count` | number | Review count | +| `size` | string | Star size | + +--- + +## Entity Components + +### ParkCard + +Park preview card. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ PARK IMAGE β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Cedar Point β”‚ +β”‚ πŸ“ Sandusky, Ohio, USA β”‚ +β”‚ 🎒 72 rides β˜… 4.8 (156) β”‚ +β”‚ β”‚ +β”‚ [Operating] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Displays:** +- Park image +- Name +- Location +- Ride count +- Rating +- Status badge + +--- + +### RideCard + +Ride preview card. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ RIDE IMAGE β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Steel Vengeance β”‚ +β”‚ Cedar Point β”‚ +β”‚ ⚑ 121 km/h πŸ“ 62.5m β”‚ +β”‚ β”‚ +β”‚ [Coaster] [Operating] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Displays:** +- Ride image +- Name +- Park name +- Key specs (speed, height) +- Category & status badges + +--- + +### ReviewCard + +User review display. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β” username β˜…β˜…β˜…β˜…β˜… β”‚ +β”‚ β”‚ β”‚ Reviewed 2 days ago β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ "Amazing coaster! The first drop is incredible β”‚ +β”‚ and the airtime moments are relentless." β”‚ +β”‚ β”‚ +β”‚ πŸ‘ 24 πŸ‘Ž 2 [Reply] [Report] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +### CreditCard + +Ride credit display. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Steel Vengeance β”‚ +β”‚ β”‚ β”‚ Cedar Point β”‚ +β”‚ β”‚ IMG β”‚ β”‚ +β”‚ β”‚ β”‚ Γ—12 rides β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Last: August 2024 β”‚ +β”‚ β”‚ +β”‚ [βˆ’] 12 [+] [Edit] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Specialty Components + +### UnitDisplay + +Displays values with unit conversion. + +``` +Speed: 121 km/h (75 mph) + or +Speed: 75 mph +``` + +**Types:** +- Speed (km/h, mph) +- Distance (m, ft) +- Height (m, ft) +- Weight (kg, lbs) + +--- + +### Map + +Interactive Leaflet map. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ πŸ“ β”‚ +β”‚ πŸ“ πŸ“ β”‚ +β”‚ πŸ“ β”‚ +β”‚ πŸ“ β”‚ +β”‚ πŸ“ β”‚ +β”‚ β”‚ +β”‚ [βˆ’][+] Β© OpenStreetMap β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Marker clusters +- Custom markers +- Popups +- Zoom controls +- Full-screen toggle + +--- + +### Timeline + +Chronological event display. + +``` +β”‚ +β”œβ”€β”€ 2024 ─── Renovation completed +β”‚ +β”œβ”€β”€ 2022 ─── Major refurbishment +β”‚ +β”œβ”€β”€ 2018 ─── Original opening +β”‚ +β–Ό +``` + +--- + +### Diff Viewer + +Side-by-side comparison. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ BEFORE β”‚ AFTER β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Name: Old Name β”‚ Name: New Name ← β”‚ +β”‚ Status: Closed β”‚ Status: Closed β”‚ +β”‚ Rides: 50 β”‚ Rides: 52 ← β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +### ImageGallery + +Photo gallery with lightbox. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ 1 β”‚ β”‚ 2 β”‚ β”‚ 3 β”‚ β”‚ +24 β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Features:** +- Grid layout +- Lightbox view +- Navigation (prev/next) +- Zoom +- Download + +--- + +### SearchAutocomplete + +Search with suggestions. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ” cedar β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 🏰 Cedar Point Park β”‚ +β”‚ 🎒 Cedar Creek Mine Ride Ride β”‚ +β”‚ 🏒 Cedar Fair Company β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +### Tooltip + +Hover information. + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +What is RMC? ───────│ Rocky Mountain β”‚ + β”‚ Construction: A β”‚ + β”‚ manufacturer known β”‚ + β”‚ for hybrid coasters β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +### HoverCard + +Rich preview on hover. + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ IMAGE β”‚ β”‚ +Cedar Point ────────────│ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ Cedar Point β”‚ + β”‚ Sandusky, Ohio β”‚ + β”‚ 🎒 72 rides β˜… 4.8 β”‚ + β”‚ β”‚ + β”‚ [View Park β†’] β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Related Documentation + +- [Site Overview](./SITE_OVERVIEW.md) +- [Design System](./DESIGN_SYSTEM.md) +- [Pages](./PAGES.md) +- [User Flows](./USER_FLOWS.md) diff --git a/source_docs/DESIGN_SYSTEM.md b/source_docs/DESIGN_SYSTEM.md new file mode 100644 index 00000000..cd32017b --- /dev/null +++ b/source_docs/DESIGN_SYSTEM.md @@ -0,0 +1,495 @@ +# ThrillWiki Design System + +> Visual identity, colors, typography, and styling guidelines + +--- + +## Brand Identity + +### Logo & Name +- **Name**: ThrillWiki (with optional "(Beta)" suffix) +- **Tagline**: The Ultimate Theme Park Database +- **Personality**: Enthusiastic, trustworthy, community-driven + +### Visual Style +- Modern and clean +- Subtle gradients and glow effects +- Smooth animations +- Card-based layouts +- Generous whitespace + +--- + +## Color System + +### Light Mode Palette + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ LIGHT MODE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Background Foreground Primary β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ White β”‚ β”‚ Dark β”‚ β”‚ Blue β”‚ β”‚ +β”‚ β”‚ #FFFFFF β”‚ β”‚ #1A1A2E β”‚ β”‚ #3B82F6 β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Secondary Muted Accent β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Light β”‚ β”‚ Gray β”‚ β”‚ Cyan β”‚ β”‚ +β”‚ β”‚ Gray β”‚ β”‚ Text β”‚ β”‚ Glow β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Dark Mode Palette + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DARK MODE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Background Foreground Primary β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β”‚ β”‚ β”‚ β”‚β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β”‚ β”‚ +β”‚ β”‚β–‘ Dark β–‘β–‘β”‚ β”‚ Light β”‚ β”‚β–“ Blue β–“β–“β”‚ β”‚ +β”‚ β”‚β–‘ Navy β–‘β–‘β”‚ β”‚ Gray β”‚ β”‚β–“ Glow β–“β–“β”‚ β”‚ +β”‚ β”‚β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β”‚ β”‚ β”‚ β”‚β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Secondary Muted Accent β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚β–’β–’β–’β–’β–’β–’β–’β–’β–’β–’β”‚ β”‚ β”‚ β”‚β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β”‚ β”‚ +β”‚ β”‚β–’ Slate β–’β–’β”‚ β”‚ Muted β”‚ β”‚β–“ Cyan β–“β–“β”‚ β”‚ +β”‚ β”‚β–’ Blue β–’β–’β”‚ β”‚ Gray β”‚ β”‚β–“ Accentβ–“β–“β”‚ β”‚ +β”‚ β”‚β–’β–’β–’β–’β–’β–’β–’β–’β–’β–’β”‚ β”‚ β”‚ β”‚β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Semantic Colors + +| Token | Purpose | Light | Dark | +|-------|---------|-------|------| +| `--background` | Page background | White | Dark navy | +| `--foreground` | Primary text | Dark gray | Light gray | +| `--primary` | Buttons, links | Blue | Blue | +| `--secondary` | Secondary UI | Light gray | Slate | +| `--muted` | Disabled, hints | Gray | Muted gray | +| `--accent` | Highlights | Cyan | Cyan | +| `--destructive` | Errors, delete | Red | Red | +| `--success` | Confirmations | Green | Green | +| `--warning` | Alerts | Amber | Amber | + +### Gradients + +| Name | Usage | +|------|-------| +| Primary Gradient | Hero sections, featured cards | +| Glow Effect | Hover states, focus rings | +| Subtle Gradient | Backgrounds, depth | + +--- + +## Typography + +### Font Family + +``` +Primary Font: Inter (Sans-Serif) +β”œβ”€β”€ Weights: 400, 500, 600, 700 +β”œβ”€β”€ Used for: All UI text +└── Fallback: system-ui, sans-serif +``` + +### Type Scale + +| Size | Name | Usage | +|------|------|-------| +| 48px | Display | Hero headlines | +| 36px | H1 | Page titles | +| 30px | H2 | Section headers | +| 24px | H3 | Card titles | +| 20px | H4 | Subheadings | +| 16px | Body | Default text | +| 14px | Small | Secondary text | +| 12px | Caption | Labels, hints | + +### Text Styles + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ HEADING STYLES β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Page Title β”‚ +β”‚ ══════════════════════ β”‚ +β”‚ 36px Β· Bold Β· Foreground β”‚ +β”‚ β”‚ +β”‚ Section Header β”‚ +β”‚ ──────────────── β”‚ +β”‚ 24px Β· Semibold Β· Foreground β”‚ +β”‚ β”‚ +β”‚ Card Title β”‚ +β”‚ 18px Β· Medium Β· Foreground β”‚ +β”‚ β”‚ +β”‚ Body Text β”‚ +β”‚ 16px Β· Regular Β· Muted Foreground β”‚ +β”‚ β”‚ +β”‚ Caption β”‚ +β”‚ 12px Β· Regular Β· Muted β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Spacing System + +### Base Unit +- 4px base unit +- All spacing is multiples of 4 + +### Spacing Scale + +| Token | Size | Common Usage | +|-------|------|--------------| +| `space-1` | 4px | Tight gaps | +| `space-2` | 8px | Icon gaps | +| `space-3` | 12px | Small padding | +| `space-4` | 16px | Default padding | +| `space-6` | 24px | Section gaps | +| `space-8` | 32px | Large gaps | +| `space-12` | 48px | Section margins | +| `space-16` | 64px | Page sections | + +--- + +## Border Radius + +| Token | Size | Usage | +|-------|------|-------| +| `rounded-sm` | 4px | Small elements | +| `rounded` | 8px | Buttons, inputs | +| `rounded-lg` | 12px | Cards | +| `rounded-xl` | 16px | Large cards | +| `rounded-full` | 9999px | Pills, avatars | + +--- + +## Shadows + +### Light Mode Shadows + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SHADOW LEVELS β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Shadow-sm β”‚ +β”‚ β”‚ β”‚ Subtle, close β”‚ +β”‚ β”‚ Card β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Shadow-md β”‚ +β”‚ β”‚ β”‚ Default cards β”‚ +β”‚ β”‚ Card β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Shadow-lg β”‚ +β”‚ β”‚ β”‚ Elevated, modals β”‚ +β”‚ β”‚ Card β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Glow Effects (Dark Mode) + +| Effect | Usage | +|--------|-------| +| Primary Glow | Focused inputs, active buttons | +| Accent Glow | Hover states on cards | +| Subtle Glow | Background depth | + +--- + +## Animation + +### Timing Functions + +| Name | Curve | Usage | +|------|-------|-------| +| `ease-smooth` | cubic-bezier(0.4, 0, 0.2, 1) | Most transitions | +| `ease-bounce` | cubic-bezier(0.68, -0.55, 0.265, 1.55) | Playful elements | +| `ease-out` | cubic-bezier(0, 0, 0.2, 1) | Exits | + +### Duration + +| Speed | Duration | Usage | +|-------|----------|-------| +| Fast | 150ms | Hover states | +| Normal | 200ms | Default transitions | +| Slow | 300ms | Page transitions | +| Slower | 500ms | Complex animations | + +### Common Animations + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ANIMATION PATTERNS β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Fade In β”‚ +β”‚ β”Œβ”€β”€β”€β” β†’ β”Œβ”€β”€β”€β” β†’ β”Œβ”€β”€β”€β” β”‚ +β”‚ β”‚ β–‘ β”‚ β”‚ β–’ β”‚ β”‚ β–ˆ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”˜ β””β”€β”€β”€β”˜ β””β”€β”€β”€β”˜ β”‚ +β”‚ 0% 50% 100% β”‚ +β”‚ β”‚ +β”‚ Slide Up β”‚ +β”‚ β”Œβ”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ ↑ β”‚ +β”‚ β””β”€β”€β”€β”˜ β”‚ +β”‚ ↑ β”‚ +β”‚ β”Œβ”€β”€β”€β” β”‚ +β”‚ β”‚ β–ˆ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Scale β”‚ +β”‚ β”Œβ”€β” β†’ β”Œβ”€β”€β”€β” β†’ β”Œβ”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”˜ β””β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ 95% 100% 100% β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Components Overview + +### Button Variants + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ BUTTON STYLES β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Primary (Default) β”‚ +β”‚ β”‚ Button β”‚ Blue background, white text β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Secondary β”‚ +β”‚ β”‚ Button β”‚ Gray background, dark text β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Outline β”‚ +β”‚ β”‚ Button β”‚ Border only, transparent background β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Ghost β”‚ +β”‚ β”‚ Button β”‚ No border, transparent background β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Destructive β”‚ +β”‚ β”‚ Delete β”‚ Red background, for dangerous actions β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Card Styles + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CARD VARIANTS β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Default Card β”‚ +β”‚ β”‚ β”‚ White/dark background β”‚ +β”‚ β”‚ Content goes here β”‚ Subtle border β”‚ +β”‚ β”‚ β”‚ Rounded corners β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Interactive Card β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Hover effect β”‚ +β”‚ β”‚ β”‚ Image β”‚ β”‚ Cursor pointer β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Scale on hover β”‚ +β”‚ β”‚ Title β”‚ β”‚ +β”‚ β”‚ Description β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Glass Card (Dark Mode) β”‚ +β”‚ β”‚β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β”‚ Semi-transparent β”‚ +β”‚ β”‚β–‘ Frosted glass effect β–‘β–‘β”‚ Backdrop blur β”‚ +β”‚ β”‚β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β”‚ Subtle glow β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Input States + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ INPUT STATES β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Default β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Placeholder text... β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Focused β”‚ +β”‚ ╔═════════════════════════════════════╗ ← Primary ring β”‚ +β”‚ β•‘ Input text β•‘ β”‚ +β”‚ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• β”‚ +β”‚ β”‚ +β”‚ Error β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” ← Red border β”‚ +β”‚ β”‚ Invalid input β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ ⚠ Error message appears here β”‚ +β”‚ β”‚ +β”‚ Disabled β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” ← Muted colors β”‚ +β”‚ β”‚ Cannot edit β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Layout Patterns + +### Page Layout + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ HEADER β”‚ +β”‚ Logo Navigation Search User Menu β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ MAIN CONTENT β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Max Width: 1280px β”‚ β”‚ +β”‚ β”‚ Padding: 16-24px (responsive) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ FOOTER β”‚ +β”‚ Links Social Copyright β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Grid System + +``` +Desktop (1024px+) Tablet (768px+) Mobile (<768px) +β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ 1 β”‚ 2 β”‚ 3 β”‚ 4 β”‚ β”‚ 1 β”‚ 2 β”‚ β”‚ 1 β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ 5 β”‚ 6 β”‚ 7 β”‚ 8 β”‚ β”‚ 3 β”‚ 4 β”‚ β”‚ 2 β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +4 columns 2 columns 1 column +``` + +--- + +## Icons + +### Icon Library +- **Lucide React** - Primary icon set +- Consistent 24px default size +- 1.5px stroke width +- Matches text color by default + +### Common Icons + +| Icon | Usage | +|------|-------| +| Search | Search functionality | +| Menu | Mobile navigation | +| User | Profile, account | +| Heart | Favorites, likes | +| Star | Ratings, reviews | +| Map Pin | Location features | +| Calendar | Dates, schedules | +| Camera | Photo uploads | +| Edit | Edit actions | +| Trash | Delete actions | +| Check | Success, completion | +| X | Close, cancel | +| ChevronRight | Navigation, expand | +| ExternalLink | External links | + +--- + +## Responsive Breakpoints + +| Breakpoint | Width | Target | +|------------|-------|--------| +| `sm` | 640px | Large phones | +| `md` | 768px | Tablets | +| `lg` | 1024px | Small laptops | +| `xl` | 1280px | Desktops | +| `2xl` | 1536px | Large screens | + +--- + +## Accessibility Guidelines + +### Color Contrast +- Normal text: 4.5:1 minimum +- Large text: 3:1 minimum +- Interactive elements: 3:1 minimum + +### Focus States +- Visible focus ring on all interactive elements +- Primary color ring (2px offset) +- Never remove focus outlines + +### Motion +- Respect `prefers-reduced-motion` +- Provide alternatives for animations +- No auto-playing videos + +--- + +## Dark Mode Considerations + +1. **Reduce contrast** - Pure white (#FFF) is too harsh; use off-white +2. **Elevate with lightness** - Higher elements are slightly lighter +3. **Subtle borders** - Use semi-transparent borders +4. **Glow effects** - Replace shadows with subtle glows +5. **Image dimming** - Slightly reduce image brightness + +--- + +## Related Documentation + +- [Site Overview](./SITE_OVERVIEW.md) +- [Pages Guide](./PAGES.md) +- [Components](./COMPONENTS.md) +- [User Flows](./USER_FLOWS.md) diff --git a/source_docs/PAGES.md b/source_docs/PAGES.md new file mode 100644 index 00000000..1531a796 --- /dev/null +++ b/source_docs/PAGES.md @@ -0,0 +1,1057 @@ +# ThrillWiki Pages Documentation + +> Complete guide to every page in the application + +--- + +## Table of Contents + +1. [Homepage](#homepage) +2. [Parks Pages](#parks-pages) +3. [Rides Pages](#rides-pages) +4. [Company Pages](#company-pages) +5. [Ride Models](#ride-models) +6. [Search](#search) +7. [Authentication](#authentication) +8. [User Profile](#user-profile) +9. [User Settings](#user-settings) +10. [Ride Credits](#ride-credits) +11. [User Lists](#user-lists) +12. [Reviews](#reviews) +13. [Photo System](#photo-system) +14. [Content Submission](#content-submission) +15. [Moderation](#moderation) +16. [Admin Pages](#admin-pages) +17. [Static Pages](#static-pages) + +--- + +## Homepage + +**Route:** `/` + +The gateway to ThrillWiki, designed for discovery and exploration. + +### Layout + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ HEADER β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ 🎒 ThrillWiki β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Search parks... β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ All β”‚ Parks β”‚ Coasters β”‚ Flat β”‚ Water β”‚ Dark β”‚ Shows β”‚...β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Card β”‚ β”‚ Card β”‚ β”‚ Card β”‚ β”‚ Card β”‚ β”‚ Card β”‚ β”‚ +β”‚ β”‚ 1 β”‚ β”‚ 2 β”‚ β”‚ 3 β”‚ β”‚ 4 β”‚ β”‚ 5 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Recent Changes Feed β”‚ β”‚ +β”‚ β”‚ β€’ Cedar Point updated β”‚ β”‚ +β”‚ β”‚ β€’ New ride added β”‚ β”‚ +β”‚ β”‚ β€’ Photo uploaded β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ FOOTER β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Features + +| Feature | Description | +|---------|-------------| +| Hero Search | Large search input with autocomplete | +| Discovery Tabs | 11 categories to explore content | +| Content Grid | Cards showing relevant items per tab | +| Recent Changes | Feed of latest community updates | + +### Discovery Tabs + +1. **All** - Mixed content from all categories +2. **Parks** - Theme and amusement parks +3. **Coasters** - Roller coasters +4. **Flat Rides** - Spinning, swinging rides +5. **Water Rides** - Flumes, rapids, splash attractions +6. **Dark Rides** - Indoor themed experiences +7. **Shows** - Entertainment and performances +8. **Transport** - Trains, monorails, boats +9. **Manufacturers** - Companies that build rides +10. **Designers** - Individuals who design rides +11. **Recent** - Newest additions + +--- + +## Parks Pages + +### Parks Listing + +**Route:** `/parks` + +Browse and filter all theme parks. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ All Parks [Grid] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Filters: β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Location β”‚ β”‚ Status β”‚ β”‚ Type β”‚ β”‚ Sort β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Park 1 β”‚ β”‚ Park 2 β”‚ β”‚ Park 3 β”‚ β”‚ Park 4 β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Location β”‚ β”‚ Location β”‚ β”‚ Location β”‚ β”‚ Location β”‚ β”‚ +β”‚ β”‚ β˜…β˜…β˜…β˜…β˜† β”‚ β”‚ β˜…β˜…β˜…β˜†β˜† β”‚ β”‚ β˜…β˜…β˜…β˜…β˜… β”‚ β”‚ β˜…β˜…β˜…β˜…β˜† β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Park 5 β”‚ β”‚ Park 6 β”‚ β”‚ Park 7 β”‚ β”‚ Park 8 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ [Load More Parks] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Filter Options + +| Filter | Options | +|--------|---------| +| Location | Country, State/Province, City | +| Status | Operating, Closed, Under Construction | +| Type | Theme Park, Amusement, Water Park | +| Sort | Name, Rating, Ride Count, Distance | + +#### View Modes + +- **Grid View** - Card layout with images +- **List View** - Compact rows with key info + +--- + +### Parks Nearby + +**Route:** `/parks/nearby` + +Location-based park discovery. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Parks Nearby [mi ↔ km] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Interactive Map β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ πŸ“ πŸ“ β”‚ β”‚ +β”‚ β”‚ πŸ“ β”‚ β”‚ +β”‚ β”‚ πŸ“ πŸ“ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Distance: ●────────────○ 50 miles β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Park 1 β”‚ β”‚ Park 2 β”‚ β”‚ Park 3 β”‚ β”‚ +β”‚ β”‚ 12 mi β”‚ β”‚ 24 mi β”‚ β”‚ 38 mi β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Features + +| Feature | Description | +|---------|-------------| +| Location Detection | Auto-detect user location | +| Interactive Map | Leaflet map with park markers | +| Radius Slider | Adjust search distance | +| Unit Toggle | Switch between miles/kilometers | +| Distance Display | Shows distance to each park | + +--- + +### Park Detail Page + +**Route:** `/parks/{park-slug}` + +Comprehensive park information. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ HERO BANNER IMAGE β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Cedar Point [Edit] [πŸ“·] β”‚ β”‚ +β”‚ β”‚ Sandusky, Ohio, USA β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Rides β”‚ β”‚ Reviews β”‚ β”‚ Rating β”‚ β”‚ Status β”‚ β”‚ Est. β”‚ β”‚ +β”‚ β”‚ 72 β”‚ β”‚ 156 β”‚ β”‚ β˜… 4.8 β”‚ β”‚ Open β”‚ β”‚ 1870 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Overview β”‚ Rides β”‚ Reviews β”‚ Photos β”‚ History β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ TAB CONTENT AREA β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Description, ride lists, reviews, photos, or β”‚ β”‚ +β”‚ β”‚ version history depending on selected tab β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Content Tabs + +| Tab | Content | +|-----|---------| +| Overview | Description, location map, contact info, operator/owner | +| Rides | Filterable list of all rides at the park | +| Reviews | User reviews with ratings | +| Photos | Community photo gallery | +| History | Version history and timeline | + +#### Quick Stats + +- Ride Count +- Review Count +- Average Rating +- Operating Status +- Established Year + +#### Actions + +| Action | Who Can Use | +|--------|-------------| +| Edit Park | Registered users | +| Add Photo | Registered users | +| Write Review | Registered users | +| Log Credit | Registered users | + +--- + +## Rides Pages + +### Rides Listing + +**Route:** `/rides` + +Browse and filter all rides. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ All Rides [Grid] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Category β”‚ Status β”‚ Park β”‚ Manufacturer β”‚ Specs β”‚ Sort β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Advanced Filters: β”‚ +β”‚ Speed: ○───────────● 0-200 km/h β”‚ +β”‚ Height: ○───────────● 0-150 m β”‚ +β”‚ Inversions: β–‘ Has inversions β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Ride 1 β”‚ β”‚ Ride 2 β”‚ β”‚ Ride 3 β”‚ β”‚ Ride 4 β”‚ β”‚ +β”‚ β”‚ Park β”‚ β”‚ Park β”‚ β”‚ Park β”‚ β”‚ Park β”‚ β”‚ +β”‚ β”‚ Category β”‚ β”‚ Category β”‚ β”‚ Category β”‚ β”‚ Category β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Filter Categories + +| Category | Options | +|----------|---------| +| Category | Coaster, Flat, Water, Dark, Show, Transport | +| Status | Operating, Closed, Under Construction | +| Park | Specific parks | +| Manufacturer | B&M, Intamin, Vekoma, etc. | +| Specifications | Speed, Height, Length, Inversions | + +--- + +### Ride Detail Page + +**Route:** `/parks/{park-slug}/rides/{ride-slug}` + +Complete ride information. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ HERO BANNER IMAGE β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Steel Vengeance [Edit] [πŸ“·] β”‚ β”‚ +β”‚ β”‚ Cedar Point β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Speed β”‚ β”‚ Height β”‚ β”‚ Length β”‚ β”‚ Inv. β”‚ β”‚ Rating β”‚ β”‚ +β”‚ β”‚ 121km/h β”‚ β”‚ 62.5m β”‚ β”‚ 1,710m β”‚ β”‚ 4 β”‚ β”‚ β˜… 4.9 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Overview β”‚ Specifications β”‚ Reviews β”‚ Photos β”‚ History β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ TAB CONTENT AREA β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Specifications Tab + +Displays ride-specific data based on category: + +**Roller Coasters:** +- Speed, Height, Length, Drop +- Inversions, G-Force +- Duration, Capacity +- Track Material, Seating Type + +**Flat Rides:** +- Height, Rotation Speed +- Swing Angle, Arm Length +- Duration, Capacity + +**Water Rides:** +- Speed, Drop Height +- Splash Height, Wetness Level +- Boat Capacity + +**Dark Rides:** +- Duration, Scenes Count +- Animatronics, Story Theme +- Projection Type + +--- + +## Company Pages + +### Company Types + +| Type | Route | Description | +|------|-------|-------------| +| Manufacturers | `/manufacturers` | Companies that build rides | +| Designers | `/designers` | Individuals/firms that design | +| Operators | `/operators` | Companies that run parks | +| Property Owners | `/owners` | Companies that own parks | + +### Company Listing + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Manufacturers [Grid] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Status β”‚ β”‚ Location β”‚ β”‚ Sort β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Logo β”‚ β”‚ Logo β”‚ β”‚ Logo β”‚ β”‚ Logo β”‚ β”‚ +β”‚ β”‚ B&M β”‚ β”‚ Intamin β”‚ β”‚ Vekoma β”‚ β”‚ Mack β”‚ β”‚ +β”‚ β”‚ 245 rides β”‚ β”‚ 312 rides β”‚ β”‚ 180 rides β”‚ β”‚ 156 rides β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Company Detail Page + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ BANNER / LOGO β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Bolliger & Mabillard [Edit] β”‚ β”‚ +β”‚ β”‚ Monthey, Switzerland β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Rides β”‚ β”‚ Founded β”‚ β”‚ Website β”‚ β”‚ +β”‚ β”‚ 245 β”‚ β”‚ 1988 β”‚ β”‚ πŸ”— β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Overview β”‚ Rides β”‚ Models β”‚ History β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ TAB CONTENT β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Ride Models + +**Route:** `/ride-models/{model-slug}` + +Standard ride designs installed at multiple parks. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ BANNER β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ SLC (Suspended Looping Coaster) [Edit] β”‚ β”‚ +β”‚ β”‚ by Vekoma β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Standard Specifications: β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Speed β”‚ β”‚ Height β”‚ β”‚ Length β”‚ β”‚ Inv. β”‚ β”‚ +β”‚ β”‚ 88km/h β”‚ β”‚ 32.6m β”‚ β”‚ 765m β”‚ β”‚ 5 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Installations (28): β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Mind Eraserβ”‚ β”‚ T3 β”‚ β”‚ Boomerang β”‚ β”‚ Kong β”‚ β”‚ +β”‚ β”‚ Six Flags β”‚ β”‚ Kentucky β”‚ β”‚ Knott's β”‚ β”‚ Walibi β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Search + +**Route:** `/search` + +Global search across all content. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Search β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ πŸ” Search parks, rides, companies... β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ All β”‚ Parks β”‚ Rides β”‚ Manufacturers β”‚ Designers β”‚ Models β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Results for "cedar": β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ 🏰 Cedar Point Park β”‚ β”‚ +β”‚ β”‚ Sandusky, Ohio, USA β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ 🎒 Cedar Creek Mine Ride Ride β”‚ β”‚ +β”‚ β”‚ Cedar Point β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ 🏒 Cedar Fair Operator β”‚ β”‚ +β”‚ β”‚ Charlotte, NC, USA β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Features + +| Feature | Description | +|---------|-------------| +| Instant Search | Results as you type | +| Type Tabs | Filter by content type | +| Keyboard Navigation | Arrow keys, Enter to select | +| Recent Searches | Quick access to history | + +--- + +## Authentication + +**Route:** `/auth` + +Sign in and sign up functionality. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Welcome Back β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Email β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Password β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Sign In β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ ─── or continue ─── β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ [Google] [Discord] β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ No account? Sign up β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Authentication Options + +| Method | Description | +|--------|-------------| +| Email/Password | Traditional login | +| Magic Link | Passwordless email login | +| Google OAuth | Sign in with Google | +| Discord OAuth | Sign in with Discord | + +### Security Features + +- CAPTCHA verification +- Email confirmation required +- MFA support (TOTP) +- Session management +- Ban check on login + +--- + +## User Profile + +**Route:** `/profile/{username}` + +Public user profile page. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β” CoasterFan123 β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ Member since 2022 β”‚ β”‚ +β”‚ β”‚ β”‚ πŸ‘€ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ 🎒 156 Credits ⭐ 42 Reviews πŸ“· 89 Photos β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ πŸ† Top 10 Contributor πŸŽ–οΈ Photo Pro ⚑ Speed Demon β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Activity β”‚ Reviews β”‚ Lists β”‚ Ride Credits β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ TAB CONTENT β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Profile Tabs + +| Tab | Content | +|-----|---------| +| Activity | Recent contributions, reviews, photos | +| Reviews | All reviews written | +| Lists | Public rankings created | +| Ride Credits | Logged ride experiences | + +--- + +## User Settings + +**Route:** `/settings` + +Account and preference management. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Settings β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Account β”‚ β”‚ Profile β”‚ β”‚ +β”‚ β”‚ Security β”‚ β”‚ ──────────────── β”‚ β”‚ +β”‚ β”‚ Privacy β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Notifications β”‚ β”‚ Username: _______________ β”‚ β”‚ +β”‚ β”‚ Location β”‚ β”‚ Display Name: ___________ β”‚ β”‚ +β”‚ β”‚ Data β”‚ β”‚ Bio: __________________ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ [Save Changes] β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Settings Sections + +| Section | Settings | +|---------|----------| +| Account | Email, password, delete account | +| Security | MFA, sessions, login history | +| Privacy | Profile visibility, activity sharing | +| Notifications | Email preferences, in-app alerts | +| Location & Units | Preferred units, home location | +| Data | Export data, submission history | + +--- + +## Ride Credits + +**Route:** `/my-credits` + +Personal ride experience tracking. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ My Ride Credits [Add Credit] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Statistics: β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Credits β”‚ β”‚ Unique β”‚ β”‚ Parks β”‚ β”‚Countriesβ”‚ β”‚ +β”‚ β”‚ 432 β”‚ β”‚ 156 β”‚ β”‚ 28 β”‚ β”‚ 12 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Filters: [Category β–Ύ] [Park β–Ύ] [Year β–Ύ] [Sort β–Ύ] β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ 🎒 Steel Vengeance Γ—12 [+ βˆ’] [Edit] β”‚ β”‚ +β”‚ β”‚ Cedar Point Last: Aug 2024 β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ 🎒 Millennium Force Γ—8 [+ βˆ’] [Edit] β”‚ β”‚ +β”‚ β”‚ Cedar Point Last: Aug 2024 β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ 🎒 Fury 325 Γ—5 [+ βˆ’] [Edit] β”‚ β”‚ +β”‚ β”‚ Carowinds Last: Jul 2024 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Features + +| Feature | Description | +|---------|-------------| +| Add Credit | Search and log new ride | +| Quick Increment | +1 button for repeat rides | +| Edit Count | Modify count or date | +| Delete | Remove credit | +| Reorder | Drag to prioritize | +| Filter | By category, park, date | +| Statistics | Totals and insights | + +--- + +## User Lists + +**Route:** `/my-lists` + +Personal rankings and collections. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ My Lists [Create List] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ πŸ“‹ Top 10 Coasters πŸ”’ Private β”‚ β”‚ +β”‚ β”‚ 10 items Β· Last updated 2 days ago β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ πŸ“‹ Bucket List Parks 🌐 Public β”‚ β”‚ +β”‚ β”‚ 25 items Β· Last updated 1 week ago β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ πŸ“‹ Best Dark Rides 🌐 Public β”‚ β”‚ +β”‚ β”‚ 15 items Β· Last updated 3 weeks ago β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### List Types + +| Type | Description | +|------|-------------| +| Rankings | Ordered lists (Top 10, Best of, etc.) | +| Collections | Unordered groups (Bucket list, Visited) | +| Private | Only visible to owner | +| Public | Visible on profile, shareable | + +--- + +## Reviews + +### Writing a Review + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Write a Review β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Rating: β˜† β˜† β˜† β˜† β˜† β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Share your experience... β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β–‘ I experienced this recently (within last year) β”‚ +β”‚ β”‚ +β”‚ [Cancel] [Submit Review] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Review Display + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”Œβ”€β”€β”€β”€β”€β” CoasterFan123 β˜…β˜…β˜…β˜…β˜… β”‚ +β”‚ β”‚ πŸ‘€ β”‚ Reviewed 2 days ago β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ "Absolutely incredible ride! The first drop is insane and β”‚ +β”‚ the airtime never stops. Best coaster I've ever ridden." β”‚ +β”‚ β”‚ +β”‚ πŸ‘ 24 πŸ‘Ž 2 [Reply] [Report] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Photo System + +### Upload Interface + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Upload Photos β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ πŸ“· β”‚ Drag & drop photos here β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ or click to browse β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Max 10 photos, 10MB each β”‚ β”‚ +β”‚ β”‚ Formats: JPG, PNG, WebP β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Queued Photos: β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ βœ“ β”‚ β”‚ ⏳ β”‚ β”‚ βœ“ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ [Cancel] [Upload All] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Photo Gallery + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Photos (156) [Upload] [Grid/List] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Content Submission + +### Submission Forms + +**Routes:** `/submit/park`, `/submit/ride`, `/submit/company`, etc. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Submit New Park β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Progress: ●───○───○───○ Step 1 of 4 β”‚ +β”‚ β”‚ +β”‚ Basic Information β”‚ +β”‚ ───────────────── β”‚ +β”‚ β”‚ +β”‚ Park Name * β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Park Type * β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Select type... β–Ύ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Operating Status * β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Select status... β–Ύ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ [Save Draft] [Back] [Next Step β†’] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Form Features + +| Feature | Description | +|---------|-------------| +| Multi-Step | Complex forms broken into steps | +| Auto-Save | Draft saved as you type | +| Validation | Real-time field validation | +| Help Text | Explanations for each field | +| Terminology | Hover for definitions | +| Unit Toggle | Switch between metric/imperial | +| JSON Import | Paste structured data | +| Sample Data | Load example for reference | + +--- + +## Moderation + +### Moderation Queue + +**Route:** `/moderation` + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Moderation Queue [Refresh] β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Filters: [Type β–Ύ] [Status β–Ύ] [Priority β–Ύ] [Assigned β–Ύ] β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ πŸ†• New Park Submission ⏱️ 2h ago β”‚ β”‚ +β”‚ β”‚ "Wonder World" by user123 β”‚ β”‚ +β”‚ β”‚ Status: Pending [Claim] [Preview]β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ ✏️ Edit Ride ⏱️ 4h ago β”‚ β”‚ +β”‚ β”‚ "Steel Vengeance" by coasterFan β”‚ β”‚ +β”‚ β”‚ Status: Claimed by mod1 [Review] β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ πŸ“· Photo Upload ⏱️ 1d ago β”‚ β”‚ +β”‚ β”‚ "Cedar Point - 5 photos" by visitor β”‚ β”‚ +β”‚ β”‚ Status: Pending [Claim] [Preview]β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Review Interface + +**Route:** `/moderation/{submission-id}` + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Review Submission [← Back to Queueβ”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ CURRENT VERSION β”‚ PROPOSED CHANGES β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Name: Cedar Point β”‚ Name: Cedar Point β”‚ β”‚ +β”‚ β”‚ Status: Open β”‚ Status: Open β”‚ β”‚ +β”‚ β”‚ Rides: 72 β”‚ Rides: 73 ← CHANGED β”‚ β”‚ +β”‚ β”‚ Description: ... β”‚ Description: ... ← + β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Reviewer Notes: β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ [Reject] [Request Changes] [Approve Selected] β”‚ +β”‚ [Approve All] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Admin Pages + +### Admin Dashboard + +**Route:** `/admin` + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Admin Dashboard β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Pending β”‚ β”‚ Users β”‚ β”‚ Errors β”‚ β”‚ Alerts β”‚ β”‚ +β”‚ β”‚ 23 β”‚ β”‚ 1,234 β”‚ β”‚ 5 β”‚ β”‚ 2 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Quick Links: β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Users β”‚ β”‚ Monitoring β”‚ β”‚ Errors β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Database β”‚ β”‚ SEO β”‚ β”‚ Blog β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Recent Activity: β”‚ +β”‚ β€’ User banned: spammer123 β”‚ +β”‚ β€’ Error spike detected β”‚ +β”‚ β€’ New moderator added β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### User Management + +**Route:** `/admin/users` + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ User Management β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Search: [___________________________] [Role β–Ύ] [Status β–Ύ] β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ πŸ‘€ user123 Role: User Active β”‚ β”‚ +β”‚ β”‚ user@email.com Joined: Jan 2024 β”‚ β”‚ +β”‚ β”‚ [View] [Edit Role] [Ban] [Delete]β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ πŸ‘€ moderator1 Role: Moderator Active β”‚ β”‚ +β”‚ β”‚ mod@email.com Joined: Mar 2023 β”‚ β”‚ +β”‚ β”‚ [View] [Edit Role] [Ban] [Delete]β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Static Pages + +### Terms of Service + +**Route:** `/terms` + +Legal terms and conditions for using ThrillWiki. + +### Privacy Policy + +**Route:** `/privacy` + +How user data is collected, used, and protected. + +### Community Guidelines + +**Route:** `/guidelines` + +Rules for contributing content and participating. + +### Contact + +**Route:** `/contact` + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Contact Us β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ Category * β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Select category... β–Ύ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Subject * β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ Message * β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ [CAPTCHA] β”‚ +β”‚ β”‚ +β”‚ [Send Message] β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Blog + +**Route:** `/blog` + +News, updates, and articles about ThrillWiki and the industry. + +--- + +## Related Documentation + +- [Site Overview](./SITE_OVERVIEW.md) +- [Design System](./DESIGN_SYSTEM.md) +- [Components](./COMPONENTS.md) +- [User Flows](./USER_FLOWS.md) diff --git a/source_docs/SITE_OVERVIEW.md b/source_docs/SITE_OVERVIEW.md new file mode 100644 index 00000000..8886b061 --- /dev/null +++ b/source_docs/SITE_OVERVIEW.md @@ -0,0 +1,244 @@ +# ThrillWiki Site Overview + +> The Ultimate Theme Park & Roller Coaster Database + +ThrillWiki is a comprehensive, community-driven platform for theme park enthusiasts to discover, document, and celebrate the world's attractions. + +--- + +## Mission Statement + +To be the definitive source for accurate, community-verified information about theme parks, rides, and the companies that create them. + +--- + +## Site Map + +``` +ThrillWiki +β”œβ”€β”€ 🏠 Homepage +β”‚ β”œβ”€β”€ Hero Search +β”‚ β”œβ”€β”€ Discovery Tabs (11 categories) +β”‚ └── Recent Changes Feed +β”‚ +β”œβ”€β”€ 🎒 Parks +β”‚ β”œβ”€β”€ /parks - All Parks Listing +β”‚ β”œβ”€β”€ /parks/nearby - Location-Based Discovery +β”‚ └── /parks/{slug} - Individual Park Pages +β”‚ β”œβ”€β”€ Overview Tab +β”‚ β”œβ”€β”€ Rides Tab +β”‚ β”œβ”€β”€ Reviews Tab +β”‚ β”œβ”€β”€ Photos Tab +β”‚ └── History Tab +β”‚ +β”œβ”€β”€ 🎠 Rides +β”‚ β”œβ”€β”€ /rides - All Rides Listing +β”‚ └── /parks/{park}/rides/{ride} - Individual Ride Pages +β”‚ β”œβ”€β”€ Overview Tab +β”‚ β”œβ”€β”€ Specifications Tab +β”‚ β”œβ”€β”€ Reviews Tab +β”‚ β”œβ”€β”€ Photos Tab +β”‚ └── History Tab +β”‚ +β”œβ”€β”€ 🏭 Companies +β”‚ β”œβ”€β”€ /manufacturers - Ride Manufacturers +β”‚ β”œβ”€β”€ /designers - Ride Designers +β”‚ β”œβ”€β”€ /operators - Park Operators +β”‚ └── /owners - Property Owners +β”‚ +β”œβ”€β”€ πŸ“‹ Ride Models +β”‚ └── /ride-models/{slug} - Standard Ride Designs +β”‚ +β”œβ”€β”€ πŸ” Search +β”‚ └── /search - Global Search with Filters +β”‚ +β”œβ”€β”€ πŸ‘€ User Features +β”‚ β”œβ”€β”€ /auth - Sign In / Sign Up +β”‚ β”œβ”€β”€ /profile/{username} - Public Profiles +β”‚ β”œβ”€β”€ /settings - Account Settings +β”‚ β”œβ”€β”€ /my-credits - Ride Credits Dashboard +β”‚ └── /my-lists - Personal Rankings +β”‚ +β”œβ”€β”€ ✏️ Contribution +β”‚ β”œβ”€β”€ /submit/* - Content Submission Forms +β”‚ └── /my-submissions - Submission History +β”‚ +β”œβ”€β”€ πŸ›‘οΈ Moderation +β”‚ β”œβ”€β”€ /moderation - Queue Dashboard +β”‚ └── /moderation/{id} - Review Interface +β”‚ +β”œβ”€β”€ βš™οΈ Admin +β”‚ β”œβ”€β”€ /admin - Dashboard +β”‚ β”œβ”€β”€ /admin/users - User Management +β”‚ β”œβ”€β”€ /admin/monitoring - System Health +β”‚ └── /admin/errors - Error Tracking +β”‚ +└── πŸ“„ Static Pages + β”œβ”€β”€ /terms - Terms of Service + β”œβ”€β”€ /privacy - Privacy Policy + β”œβ”€β”€ /guidelines - Community Guidelines + β”œβ”€β”€ /contact - Contact Form + └── /blog - News & Updates +``` + +--- + +## Core Features + +### 1. Discovery & Exploration + +| Feature | Description | +|---------|-------------| +| **Global Search** | Find any park, ride, or company instantly | +| **Parks Nearby** | Location-based discovery with interactive map | +| **Advanced Filters** | Filter by type, status, location, specifications | +| **Discovery Tabs** | 11 curated categories on homepage | +| **Trending Content** | See what's popular in the community | + +### 2. Content & Information + +| Feature | Description | +|---------|-------------| +| **Detailed Park Pages** | Complete information, photos, reviews | +| **Comprehensive Ride Specs** | Technical details in user's preferred units | +| **Company Profiles** | Manufacturers, designers, operators | +| **Ride Models** | Standard designs with all installations | +| **Photo Galleries** | Community-uploaded imagery | +| **Historical Records** | Track changes over time | + +### 3. Community Features + +| Feature | Description | +|---------|-------------| +| **Reviews & Ratings** | Share experiences with star ratings | +| **Ride Credits** | Log rides you've experienced | +| **Personal Lists** | Create and share rankings | +| **Leaderboards** | Recognition for top contributors | +| **Badges** | Achievement system | + +### 4. Contribution System + +| Feature | Description | +|---------|-------------| +| **Submit New Content** | Add parks, rides, companies | +| **Edit Existing** | Suggest improvements | +| **Photo Uploads** | Share your images | +| **Moderation Queue** | Quality review process | +| **Version History** | Track all changes | + +--- + +## User Roles + +### Anonymous Visitors +- Browse all public content +- Use search and filters +- View photos and reviews +- See ride specifications + +### Registered Users +- All anonymous features, plus: +- Write reviews and ratings +- Log ride credits +- Create personal lists +- Upload photos +- Submit new content +- Edit existing content + +### Contributors +- All registered features, plus: +- Higher submission trust +- Skip moderation for minor edits +- Recognition on leaderboards + +### Moderators +- All contributor features, plus: +- Access moderation queue +- Approve/reject submissions +- Review photos +- Manage content quality + +### Administrators +- All moderator features, plus: +- User management +- System configuration +- Error monitoring +- Database maintenance + +--- + +## Key User Journeys + +### Discovery Journey +``` +Homepage β†’ Search/Browse β†’ Park/Ride Page β†’ Explore Related Content +``` + +### Engagement Journey +``` +View Content β†’ Sign Up β†’ Write Review β†’ Log Credit β†’ Create List +``` + +### Contribution Journey +``` +Find Missing Info β†’ Submit Edit β†’ Await Moderation β†’ See Changes Live +``` + +### Recognition Journey +``` +Contribute Content β†’ Earn Points β†’ Climb Leaderboard β†’ Earn Badges +``` + +--- + +## Content Statistics (Typical) + +| Content Type | Description | +|--------------|-------------| +| Parks | Theme parks, amusement parks, water parks worldwide | +| Rides | Roller coasters, flat rides, water rides, dark rides | +| Companies | Manufacturers, designers, operators, owners | +| Reviews | User experiences and ratings | +| Photos | Community-uploaded images | +| Ride Credits | Personal ride experiences logged | + +--- + +## Platform Availability + +| Platform | Status | +|----------|--------| +| Web (Desktop) | βœ… Full Experience | +| Web (Tablet) | βœ… Responsive Design | +| Web (Mobile) | βœ… Touch-Optimized | +| Native Apps | ❌ Not Available | + +--- + +## Accessibility + +- Full keyboard navigation +- Screen reader compatible +- High contrast support +- Respects reduced motion preferences +- Touch-friendly on mobile + +--- + +## Internationalization + +| Feature | Status | +|---------|--------| +| Language | English only | +| Units | Metric/Imperial toggle | +| Dates | Localized formatting | +| Currency | Not applicable | + +--- + +## Next Steps + +- [Design System](./DESIGN_SYSTEM.md) - Visual identity and styling +- [Pages Guide](./PAGES.md) - Detailed page documentation +- [Components](./COMPONENTS.md) - UI component library +- [User Flows](./USER_FLOWS.md) - Journey diagrams diff --git a/source_docs/USER_FLOWS.md b/source_docs/USER_FLOWS.md new file mode 100644 index 00000000..83dde690 --- /dev/null +++ b/source_docs/USER_FLOWS.md @@ -0,0 +1,881 @@ +# ThrillWiki User Flows + +> Visual diagrams of user journeys and system interactions + +--- + +## Table of Contents + +1. [Discovery Flows](#discovery-flows) +2. [Authentication Flows](#authentication-flows) +3. [Content Viewing Flows](#content-viewing-flows) +4. [Contribution Flows](#contribution-flows) +5. [Engagement Flows](#engagement-flows) +6. [Moderation Flows](#moderation-flows) +7. [Admin Flows](#admin-flows) + +--- + +## Discovery Flows + +### Homepage Discovery Journey + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ USER ARRIVES β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Search β”‚ β”‚ Discoveryβ”‚ β”‚ Recent β”‚ β”‚ +β”‚ β”‚ Bar β”‚ β”‚ Tabs β”‚ β”‚ Changes β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Instant β”‚ β”‚ Content β”‚ β”‚ Click β”‚ β”‚ +β”‚ β”‚ Results β”‚ β”‚ Grid β”‚ β”‚ Item β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Entity β”‚ β”‚ +β”‚ β”‚ Page β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Search Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User Types Query β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Debounce Input β”‚ β”‚ +β”‚ β”‚ (300ms) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Search API β”‚ β”‚ +β”‚ β”‚ Called β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Results β”‚ β”‚ No β”‚ β”‚ +β”‚ β”‚ Found β”‚ β”‚ Results β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Display β”‚ β”‚ Empty β”‚ β”‚ +β”‚ β”‚ List β”‚ β”‚ State β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ User Selects Result β”‚ β”‚ +β”‚ β”‚ (Click or Keyboard) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ Navigate to Page β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Parks Nearby Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User Opens Nearby Page β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Check Location β”‚ β”‚ +β”‚ β”‚ Preferences β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Saved β”‚ β”‚ No Saved β”‚ β”‚ +β”‚ β”‚Location β”‚ β”‚ Location β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Request Browser β”‚ β”‚ +β”‚ β”‚ β”‚ Geolocation β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β–Ό β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Granted β”‚ β”‚ Denied β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β–Ό β”‚ +β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ Show Manual β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ Location Entryβ”‚ β”‚ +β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Load Parks β”‚ β”‚ +β”‚ β”‚ Within Radiusβ”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Map β”‚ β”‚ List β”‚ β”‚ +β”‚ β”‚ View β”‚ β”‚ View β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Authentication Flows + +### Sign Up Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User Clicks "Sign Up" β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Choose Sign Up Method β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Email β”‚ β”‚ Magic β”‚ β”‚ OAuth β”‚ β”‚ +β”‚ β”‚Password β”‚ β”‚ Link β”‚ β”‚(Google/ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚ Discord) β”‚ β”‚ +β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β–Ό β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ Fill β”‚ β”‚ Enter β”‚ β”‚ β”‚ +β”‚ β”‚ Form β”‚ β”‚ Email β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ CAPTCHA Verification β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Email Sent β”‚ β”‚ +β”‚ β”‚ (Confirmation) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ User Clicks Link β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Email Verified β”‚ β”‚ +β”‚ β”‚ Account Active β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Redirect to β”‚ β”‚ +β”‚ β”‚ Profile Setup β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Sign In Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User Clicks "Sign In" β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Enter Credentials β”‚ β”‚ +β”‚ β”‚ (Email/Password or OAuth)β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Validate Credentials β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Success β”‚ β”‚ Failed β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Show Error β”‚ β”‚ +β”‚ β”‚ β”‚ (Too many β”‚ β”‚ +β”‚ β”‚ β”‚ attempts = β”‚ β”‚ +β”‚ β”‚ β”‚ lockout) β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Check for MFA β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ MFA β”‚ β”‚ No MFA β”‚ β”‚ +β”‚ β”‚ Enabled β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ Enter TOTP β”‚ β”‚ β”‚ +β”‚ β”‚ Code β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ └──────────────── β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Check Ban Status β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Banned β”‚ β”‚ Not Bannedβ”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Show β”‚ β”‚ Create Sessionβ”‚ β”‚ +β”‚ β”‚ Message β”‚ β”‚ Redirect Home β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Content Viewing Flows + +### Park Page Journey + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User Arrives at Park Page β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ View Hero Section β”‚ β”‚ +β”‚ β”‚ (Banner, Name, Stats) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ SELECT TAB β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ Overview β”‚ Rides β”‚ Reviews β”‚ Photos β”‚ History β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Park β”‚β”‚ Ride β”‚β”‚ Review β”‚β”‚ Photo β”‚β”‚ Version β”‚ β”‚ +β”‚ β”‚ Info, β”‚β”‚ List β”‚β”‚ List β”‚β”‚ Gallery β”‚β”‚ History β”‚ β”‚ +β”‚ β”‚ Map, β”‚β”‚ with β”‚β”‚ with β”‚β”‚ β”‚β”‚ Timeline β”‚ β”‚ +β”‚ β”‚ Contact β”‚β”‚ Filters β”‚β”‚ Ratings β”‚β”‚ β”‚β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ CLICK ITEM FOR DETAIL β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β–Ό β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Ride Page β”‚ β”‚ Lightbox β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ └──────────────────────────────────────────────────────▢│ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ USER ACTIONS β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ Edit Park β”‚ Write Review β”‚ Upload Photo β”‚ Log Creditβ”‚ β”‚ +β”‚ β”‚ (if logged) β”‚ (if logged) β”‚ (if logged) β”‚(if logged)β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Contribution Flows + +### Submit New Content Flow (The Sacred Pipeline) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ THE SACRED PIPELINE β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ USER β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ SUBMISSION FORM β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Multi-step wizard β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Auto-save drafts β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Validation β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Unit conversion β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ SUBMISSION RECORD CREATED β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ (content_submissions table) β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Status: "pending" β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ MODERATION β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ MODERATION QUEUE β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Appears in moderator dashboard β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Sorted by priority/age β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ MODERATOR CLAIMS ITEM β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Locks item for 30 minutes β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Prevents conflicts β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ REVIEW INTERFACE β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Side-by-side diff β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Field-by-field review β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Preview changes β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β–Ό β–Ό β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ APPROVE β”‚ β”‚ REQUEST β”‚ β”‚ REJECT β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ CHANGES β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Notify User β”‚ β”‚ Notify User β”‚ β”‚ +β”‚ β”‚ β”‚ To Revise β”‚ β”‚ With Reason β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ APPROVAL β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ EDGE FUNCTION TRIGGERED β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Transaction begins β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Create/update entity β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Create version record β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Update submission status β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Transaction commits β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ VERSION RECORD CREATED β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Full snapshot of data β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Version number incremented β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ is_current = true β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ ENTITY NOW VISIBLE β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Appears in listings β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Searchable β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β€’ Has public URL β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Edit Content Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User Clicks "Edit" on Entity β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Check User Role β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Moderator+ β”‚ β”‚ Regular User β”‚ β”‚ +β”‚ β”‚ (Skip mod) β”‚ β”‚ (Needs review)β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ └──────────────────────────── β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Load Edit Form β”‚ β”‚ +β”‚ β”‚ (Pre-filled with β”‚ β”‚ +β”‚ β”‚ current data) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ User Makes Changes β”‚ β”‚ +β”‚ β”‚ (Auto-save enabled) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Add Submission Note β”‚ β”‚ +β”‚ β”‚ (Explain changes) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Submit for Review β”‚ β”‚ +β”‚ β”‚ or Direct Apply β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Photo Upload Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User Clicks "Upload Photo" β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Open Upload Dialog β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Drag & Drop or Browse β”‚ β”‚ +β”‚ β”‚ (Max 10 photos) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ For Each Photo: β”‚ β”‚ +β”‚ β”‚ β€’ Validate format β”‚ β”‚ +β”‚ β”‚ β€’ Check file size β”‚ β”‚ +β”‚ β”‚ β€’ Show preview β”‚ β”‚ +β”‚ β”‚ β€’ Optional: Edit/Crop β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Click "Upload" β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Get CloudFlare Direct β”‚ β”‚ +β”‚ β”‚ Upload URL β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Upload to CloudFlare β”‚ β”‚ +β”‚ β”‚ (Show progress) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Create Photo Submission β”‚ β”‚ +β”‚ β”‚ (Status: Pending) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β†’ Moderation Queue β”‚ β”‚ +β”‚ β”‚ Photo visible after β”‚ β”‚ +β”‚ β”‚ approval β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Engagement Flows + +### Write Review Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ User on Park/Ride Page β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Click "Write Review" β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Logged In β”‚ β”‚ Not Logged In β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Redirect to Login β”‚ β”‚ +β”‚ β”‚ β”‚ (Return after) β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Check Existing Review β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Has Review β”‚ β”‚ No Review β”‚ β”‚ +β”‚ β”‚ (Edit mode) β”‚ β”‚ (Create mode) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Review Form β”‚ β”‚ +β”‚ β”‚ β€’ Star rating (1-5) β”‚ β”‚ +β”‚ β”‚ β€’ Written review text β”‚ β”‚ +β”‚ β”‚ β€’ "Experienced recently" β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Submit Review β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Review Visible β”‚ β”‚ +β”‚ β”‚ (No moderation for β”‚ β”‚ +β”‚ β”‚ reviews by default) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Log Ride Credit Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ ENTRY POINTS β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ Ride Page β”‚ Credits Page β”‚ Quick Add β”‚ β”‚ +β”‚ β”‚ "Log Credit" β”‚ "Add Credit" β”‚ (Previously β”‚ β”‚ +β”‚ β”‚ Button β”‚ Button β”‚ logged rides) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ RIDE SEARCH (if not from ride) β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Search by name β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Filter by park β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Recent rides shown β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ └──────────────────────────── β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ CREDIT DETAILS β”‚ β”‚ +β”‚ β”‚ β€’ Ride count (default: 1) β”‚ β”‚ +β”‚ β”‚ β€’ Date (optional) β”‚ β”‚ +β”‚ β”‚ β€’ Notes (optional) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ SAVE CREDIT β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ New Credit β”‚ β”‚ Update β”‚ +β”‚ β”‚ Created β”‚ β”‚ Existing β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ QUICK INCREMENT β”‚ β”‚ +β”‚ β”‚ On credit card: [βˆ’] 12 [+] β”‚ β”‚ +β”‚ β”‚ Instantly updates count without dialog β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Moderation Flows + +### Moderator Queue Workflow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ Moderator Opens Queue β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ View Pending Items β”‚ β”‚ +β”‚ β”‚ (Sorted by priority/age) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Apply Filters β”‚ β”‚ +β”‚ β”‚ β€’ Type (park/ride/etc) β”‚ β”‚ +β”‚ β”‚ β€’ Status β”‚ β”‚ +β”‚ β”‚ β€’ Priority β”‚ β”‚ +β”‚ β”‚ β€’ Assigned to me β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Select Item to Review β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ CLAIM ITEM β”‚ β”‚ +β”‚ β”‚ β€’ Locks for 30 mins β”‚ β”‚ +β”‚ β”‚ β€’ Others can't claim β”‚ β”‚ +β”‚ β”‚ β€’ Can unclaim if needed β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ REVIEW INTERFACE β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ CURRENT DATA β”‚ PROPOSED CHANGES β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Name: Cedar Point β”‚ Name: Cedar Point β”‚ β”‚ +β”‚ β”‚ Status: Open β”‚ Status: Open β”‚ β”‚ +β”‚ β”‚ Rides: 72 β”‚ Rides: 73 ← CHANGED β”‚ β”‚ +β”‚ β”‚ Desc: ... β”‚ Desc: ... (expanded) ← + β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Add Reviewer Notes β”‚ β”‚ +β”‚ β”‚ (Internal notes for β”‚ β”‚ +β”‚ β”‚ decision reasoning) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ APPROVE β”‚ β”‚ REJECT β”‚ β”‚ REQUEST β”‚ β”‚ +β”‚ β”‚ ALL β”‚ β”‚ β”‚ β”‚ CHANGES β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β–Ό β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ Notify User β”‚ β”‚ Notify User β”‚ β”‚ +β”‚ β”‚ β”‚ (Rejected) β”‚ β”‚ (Changes β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ Needed) β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ APPROVAL PROCESSING β”‚ β”‚ +β”‚ β”‚ β€’ Edge function called β”‚ β”‚ +β”‚ β”‚ β€’ Transaction executed β”‚ β”‚ +β”‚ β”‚ β€’ Entity updated β”‚ β”‚ +β”‚ β”‚ β€’ Version created β”‚ β”‚ +β”‚ β”‚ β€’ User notified β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Admin Flows + +### User Management Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ Admin Opens User Management β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Search/Filter Users β”‚ β”‚ +β”‚ β”‚ β€’ By username/email β”‚ β”‚ +β”‚ β”‚ β€’ By role β”‚ β”‚ +β”‚ β”‚ β€’ By status β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Select User β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ USER PROFILE VIEW β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Username: coasterFan123 β”‚ β”‚ +β”‚ β”‚ Email: user@email.com β”‚ β”‚ +β”‚ β”‚ Role: User β”‚ β”‚ +β”‚ β”‚ Status: Active β”‚ β”‚ +β”‚ β”‚ Joined: Jan 2024 β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Stats: 45 submissions, 12 reviews, 89 credits β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Change β”‚ β”‚ Ban β”‚ β”‚ Unban β”‚ β”‚ Delete β”‚ β”‚ +β”‚ β”‚ Role β”‚ β”‚ User β”‚ β”‚ User β”‚ β”‚ User β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Select β”‚ β”‚ Enter Reason β”‚ β”‚ Confirm β”‚ β”‚ Confirm β”‚ β”‚ +β”‚ β”‚ New Roleβ”‚ β”‚ Set Duration β”‚ β”‚ Unban β”‚ β”‚ (Irreversible)β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Action Logged β”‚ β”‚ +β”‚ β”‚ (Audit Trail) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Notification Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ EVENT TRIGGERS β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ Submission β”‚ Review β”‚ Social β”‚ System β”‚ β”‚ +β”‚ β”‚ Status β”‚ Response β”‚ Interaction β”‚ Alert β”‚ β”‚ +β”‚ β”‚ Changed β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ CHECK USER PREFERENCES β”‚ β”‚ +β”‚ β”‚ β€’ Notification enabled? β”‚ β”‚ +β”‚ β”‚ β€’ Email enabled? β”‚ β”‚ +β”‚ β”‚ β€’ In-app enabled? β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ In-App β”‚ β”‚ Email β”‚ β”‚ None β”‚ β”‚ +β”‚ β”‚ Only β”‚ β”‚ + In-App β”‚ β”‚ (Opted β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ Out) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ NOVU β”‚ β”‚ NOVU β”‚ β”‚ +β”‚ β”‚ In-App β”‚ β”‚ Email + App β”‚ β”‚ +β”‚ β”‚ Channel β”‚ β”‚ Channels β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ └───────────────────── β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ NOTIFICATION DELIVERED β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ NOTIFICATION β”‚ β”‚ EMAIL β”‚ β”‚ +β”‚ β”‚ BELL β”‚ β”‚ INBOX β”‚ β”‚ +β”‚ β”‚ (πŸ”” Badge) β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ NOTIFICATION FEED β”‚ β”‚ +β”‚ β”‚ β€’ Mark as read β”‚ β”‚ +β”‚ β”‚ β€’ Click to navigate β”‚ β”‚ +β”‚ β”‚ β€’ Mark all as read β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Related Documentation + +- [Site Overview](./SITE_OVERVIEW.md) +- [Design System](./DESIGN_SYSTEM.md) +- [Pages](./PAGES.md) +- [Components](./COMPONENTS.md)