mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 04:05:25 -05:00
lol
This commit is contained in:
85
.agent/workflows/comply.md
Normal file
85
.agent/workflows/comply.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
description: Ensure compliance with source_docs specifications - Continuous guard
|
||||
---
|
||||
|
||||
# Source Docs Compliance Workflow
|
||||
|
||||
You are now in **Compliance Guard Mode**. The documents in `source_docs/` are LAW. Every code change must comply with these specifications.
|
||||
|
||||
## The Constitution
|
||||
|
||||
The following documents are the single source of truth:
|
||||
- `source_docs/SITE_OVERVIEW.md` - High-level product vision
|
||||
- `source_docs/DESIGN_SYSTEM.md` - Visual identity, colors, typography, gradients
|
||||
- `source_docs/COMPONENTS.md` - Component specifications and patterns
|
||||
- `source_docs/PAGES.md` - Page layouts and content
|
||||
- `source_docs/USER_FLOWS.md` - User journeys and interaction specifications
|
||||
|
||||
## Before Making ANY Code Change
|
||||
|
||||
1. **Identify Relevant Specs**: Determine which source_docs apply to the change
|
||||
2. **Read the Spec**: View the relevant sections to understand requirements
|
||||
3. **Check for Deviations**: Compare current/proposed code against the spec
|
||||
4. **Cite Your Sources**: Reference specific line numbers when claiming compliance
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
### For Components (check against COMPONENTS.md, DESIGN_SYSTEM.md):
|
||||
- [ ] Uses correct component structure (header, content, footer)
|
||||
- [ ] Uses primary color palette (blue for primary, NOT emerald/teal unless specified)
|
||||
- [ ] Uses centralized constants instead of hardcoded options
|
||||
- [ ] Follows established patterns (sticky footer, tab navigation, etc.)
|
||||
- [ ] Proper button variants per spec
|
||||
- [ ] Dark mode support
|
||||
|
||||
### For User Flows (check against USER_FLOWS.md):
|
||||
- [ ] All required fields present (e.g., submission notes for edits)
|
||||
- [ ] Proper validation implemented
|
||||
- [ ] Error states handled per spec
|
||||
- [ ] Success feedback matches spec
|
||||
|
||||
### For Forms:
|
||||
- [ ] Options imported from `~/utils/constants.ts`
|
||||
- [ ] Backend-synced choices (check `backend/apps/*/choices.py`)
|
||||
- [ ] Required fields marked and validated
|
||||
- [ ] Proper help text and hints
|
||||
|
||||
## When Deviation is Found
|
||||
|
||||
1. **STOP** - Do not proceed with the current approach
|
||||
2. **LOG** - Document the deviation with:
|
||||
- Requirement (from source_docs)
|
||||
- Current implementation
|
||||
- Proposed fix
|
||||
3. **FIX** - Implement the compliant solution
|
||||
4. **VERIFY** - Ensure the fix matches the spec
|
||||
|
||||
## Status Tags
|
||||
|
||||
Use these in any audit or review:
|
||||
- `[OK]` - Compliant with spec
|
||||
- `[DEVIATION]` - Implemented differently than spec
|
||||
- `[MISSING]` - Required feature not implemented
|
||||
- `[RISK]` - Potential issue that needs investigation
|
||||
|
||||
## Key Constants Files
|
||||
|
||||
Always use these instead of hardcoding:
|
||||
- `frontend/app/utils/constants.ts` - Status configs, park types, etc.
|
||||
- `backend/apps/parks/choices.py` - Park status and type definitions
|
||||
- `backend/apps/rides/choices.py` - Ride status and category definitions
|
||||
- `backend/apps/moderation/choices.py` - Submission status definitions
|
||||
|
||||
## Example Audit Output
|
||||
|
||||
```markdown
|
||||
## Compliance Audit: ComponentName.vue
|
||||
|
||||
### Checking Against: [List relevant docs]
|
||||
|
||||
| Requirement | Source | Status |
|
||||
|-------------|--------|--------|
|
||||
| Uses primary gradient | DESIGN_SYSTEM.md L91-98 | [OK] |
|
||||
| Submission note field | USER_FLOWS.md L473-476 | [MISSING] → FIX |
|
||||
| Sticky footer pattern | COMPONENTS.md L583-602 | [DEVIATION] → FIX |
|
||||
```
|
||||
168
.agent/workflows/migrate-component.md
Normal file
168
.agent/workflows/migrate-component.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
description: Migrate a React component from thrillwiki-87 to Vue/Nuxt
|
||||
---
|
||||
|
||||
# Migrate Component Workflow
|
||||
|
||||
Convert a React component from thrillwiki-87 (the authoritative source) to Vue 3 Composition API for the Nuxt 4 project.
|
||||
|
||||
## Step 1: Locate Source Component
|
||||
|
||||
Find the React component in thrillwiki-87:
|
||||
```bash
|
||||
# React components are in:
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
|
||||
```
|
||||
|
||||
Common component directories:
|
||||
- `auth/` - Authentication components
|
||||
- `parks/` - Park-related components
|
||||
- `rides/` - Ride-related components
|
||||
- `forms/` - Form components
|
||||
- `moderation/` - Moderation queue components
|
||||
- `ui/` - Base UI primitives (shadcn-ui)
|
||||
- `common/` - Shared utilities
|
||||
- `layout/` - Layout components
|
||||
|
||||
## Step 2: Analyze React Component
|
||||
|
||||
Extract these patterns from the source:
|
||||
|
||||
```tsx
|
||||
// Props interface
|
||||
interface ComponentProps {
|
||||
prop1: string
|
||||
prop2?: number
|
||||
}
|
||||
|
||||
// State hooks
|
||||
const [value, setValue] = useState<Type>(initial)
|
||||
|
||||
// Effects
|
||||
useEffect(() => { /* logic */ }, [deps])
|
||||
|
||||
// Event handlers
|
||||
const handleClick = () => { /* logic */ }
|
||||
|
||||
// Render return
|
||||
return <JSX />
|
||||
```
|
||||
|
||||
## Step 3: Translate to Vue 3
|
||||
|
||||
### Props
|
||||
```tsx
|
||||
// React
|
||||
interface Props { name: string; count?: number }
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string
|
||||
count?: number
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
### State
|
||||
```tsx
|
||||
// React
|
||||
const [value, setValue] = useState<string>('')
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
<script setup lang="ts">
|
||||
const value = ref<string>('')
|
||||
</script>
|
||||
```
|
||||
|
||||
### Effects
|
||||
```tsx
|
||||
// React
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [id])
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
<script setup lang="ts">
|
||||
watch(() => id, () => {
|
||||
fetchData()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
```
|
||||
|
||||
### Events
|
||||
```tsx
|
||||
// React
|
||||
onClick={() => onSelect(item)}
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
@click="emit('select', item)"
|
||||
```
|
||||
|
||||
## Step 4: Map UI Components
|
||||
|
||||
Translate shadcn-ui to Nuxt UI:
|
||||
|
||||
| shadcn-ui | Nuxt UI |
|
||||
|-----------|---------|
|
||||
| `<Button>` | `<UButton>` |
|
||||
| `<Card>` | `<UCard>` |
|
||||
| `<Dialog>` | `<UModal>` |
|
||||
| `<Input>` | `<UInput>` |
|
||||
| `<Select>` | `<USelect>` |
|
||||
| `<Tabs>` | `<UTabs>` |
|
||||
| `<Badge>` | `<UBadge>` |
|
||||
|
||||
## Step 5: Update API Calls
|
||||
|
||||
Replace Supabase with Django API:
|
||||
|
||||
```tsx
|
||||
// React (Supabase)
|
||||
const { data } = await supabase.from('parks').select('*')
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue (Django) -->
|
||||
<script setup lang="ts">
|
||||
const { data } = await useApi<Park[]>('/parks/')
|
||||
</script>
|
||||
```
|
||||
|
||||
## Step 6: Place in Nuxt Structure
|
||||
|
||||
Target paths:
|
||||
```
|
||||
frontend/app/components/
|
||||
├── auth/ # Auth components
|
||||
├── cards/ # Card components
|
||||
├── common/ # Shared utilities
|
||||
├── modals/ # Modal dialogs
|
||||
├── rides/ # Ride components
|
||||
└── ui/ # Base UI components
|
||||
```
|
||||
|
||||
## Step 7: Verify Parity
|
||||
|
||||
- [ ] All props from source are present
|
||||
- [ ] All state variables ported
|
||||
- [ ] All event handlers work
|
||||
- [ ] Styling matches (Tailwind classes)
|
||||
- [ ] Loading states present
|
||||
- [ ] Error states handled
|
||||
- [ ] Dark mode works
|
||||
|
||||
## Example Migration
|
||||
|
||||
**Source**: `thrillwiki-87/src/components/parks/ParkCard.tsx`
|
||||
**Target**: `frontend/app/components/cards/ParkCard.vue`
|
||||
|
||||
Check existing target. If it exists, compare and add missing features. If not, create new.
|
||||
223
.agent/workflows/migrate-hook.md
Normal file
223
.agent/workflows/migrate-hook.md
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
description: Convert a React hook from thrillwiki-87 to a Vue composable
|
||||
---
|
||||
|
||||
# Migrate Hook Workflow
|
||||
|
||||
Convert a React hook from thrillwiki-87 to a Vue 3 composable for the Nuxt 4 project.
|
||||
|
||||
## Step 1: Locate Source Hook
|
||||
|
||||
Find the React hook in thrillwiki-87:
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
|
||||
```
|
||||
|
||||
Key hooks (80+ total):
|
||||
- `useAuth.tsx` - Authentication state
|
||||
- `useModerationQueue.ts` - Moderation logic (21KB)
|
||||
- `useEntityVersions.ts` - Version history (14KB)
|
||||
- `useSearch.tsx` - Search functionality
|
||||
- `useUnitPreferences.ts` - Unit conversion
|
||||
- `useProfile.tsx` - User profile
|
||||
- `useLocations.ts` - Location data
|
||||
- `useRideCreditFilters.ts` - Credit filtering
|
||||
|
||||
## Step 2: Analyze Hook Pattern
|
||||
|
||||
Extract the hook structure:
|
||||
|
||||
```tsx
|
||||
export function useFeature(params: Params) {
|
||||
// State
|
||||
const [data, setData] = useState<Type>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [dependency])
|
||||
|
||||
// Actions
|
||||
const doSomething = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.call()
|
||||
setData(result)
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { data, loading, error, doSomething }
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Convert to Composable
|
||||
|
||||
### Basic Structure
|
||||
```tsx
|
||||
// React
|
||||
export function useFeature() {
|
||||
const [value, setValue] = useState('')
|
||||
return { value, setValue }
|
||||
}
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
export function useFeature() {
|
||||
const value = ref('')
|
||||
|
||||
function setValue(newValue: string) {
|
||||
value.value = newValue
|
||||
}
|
||||
|
||||
return { value, setValue }
|
||||
}
|
||||
```
|
||||
|
||||
### State Conversions
|
||||
```tsx
|
||||
// React
|
||||
const [count, setCount] = useState(0)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
const count = ref(0)
|
||||
const user = ref<User | null>(null)
|
||||
const items = ref<Item[]>([])
|
||||
```
|
||||
|
||||
### Effect Conversions
|
||||
```tsx
|
||||
// React - Run on mount
|
||||
useEffect(() => {
|
||||
initialize()
|
||||
}, [])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
onMounted(() => {
|
||||
initialize()
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React - Watch dependency
|
||||
useEffect(() => {
|
||||
fetchData(id)
|
||||
}, [id])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
watch(() => id, (newId) => {
|
||||
fetchData(newId)
|
||||
}, { immediate: true })
|
||||
```
|
||||
|
||||
### Supabase → Django API
|
||||
```tsx
|
||||
// React (Supabase)
|
||||
const { data } = await supabase
|
||||
.from('parks')
|
||||
.select('*')
|
||||
.eq('slug', slug)
|
||||
.single()
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue (Django)
|
||||
const api = useApi()
|
||||
const { data } = await api<Park>(`/parks/${slug}/`)
|
||||
```
|
||||
|
||||
## Step 4: Handle Complex Patterns
|
||||
|
||||
### useCallback → Plain Function
|
||||
```tsx
|
||||
// React
|
||||
const memoizedFn = useCallback(() => {
|
||||
doSomething(dep)
|
||||
}, [dep])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue - Usually no memo needed
|
||||
function doSomething() {
|
||||
// Vue's reactivity handles this
|
||||
}
|
||||
```
|
||||
|
||||
### useMemo → computed
|
||||
```tsx
|
||||
// React
|
||||
const derived = useMemo(() => expensiveCalc(data), [data])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
const derived = computed(() => expensiveCalc(data.value))
|
||||
```
|
||||
|
||||
### Custom Hook Composition
|
||||
```tsx
|
||||
// React
|
||||
function useFeature() {
|
||||
const auth = useAuth()
|
||||
const { data } = useQuery(...)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
export function useFeature() {
|
||||
const { user } = useAuth()
|
||||
const api = useApi()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Target Location
|
||||
|
||||
Place composables in:
|
||||
```
|
||||
frontend/app/composables/
|
||||
├── useApi.ts # Base API client
|
||||
├── useAuth.ts # Authentication
|
||||
├── useParksApi.ts # Parks API
|
||||
├── useRidesApi.ts # Rides API
|
||||
├── useModeration.ts # Moderation queue
|
||||
└── use[Feature].ts # New composables
|
||||
```
|
||||
|
||||
## Step 6: Verify Parity
|
||||
|
||||
- [ ] All returned values present
|
||||
- [ ] All actions/methods work
|
||||
- [ ] State updates correctly
|
||||
- [ ] API calls translated
|
||||
- [ ] Error handling maintained
|
||||
- [ ] Loading states work
|
||||
- [ ] TypeScript types correct
|
||||
|
||||
## Priority Hooks to Migrate
|
||||
|
||||
| Hook | Size | Complexity |
|
||||
|------|------|------------|
|
||||
| useModerationQueue.ts | 21KB | High |
|
||||
| useEntityVersions.ts | 14KB | High |
|
||||
| useAuth.tsx | 11KB | Medium |
|
||||
| useAutoComplete.ts | 10KB | Medium |
|
||||
| useRateLimitAlerts.ts | 10KB | Medium |
|
||||
| useRideCreditFilters.ts | 9KB | Medium |
|
||||
| useAdminSettings.ts | 9KB | Medium |
|
||||
183
.agent/workflows/migrate-page.md
Normal file
183
.agent/workflows/migrate-page.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
description: Migrate a React page from thrillwiki-87 to Nuxt 4
|
||||
---
|
||||
|
||||
# Migrate Page Workflow
|
||||
|
||||
Port a React page from thrillwiki-87 (the authoritative source) to a Nuxt 4 page.
|
||||
|
||||
## Step 1: Locate Source Page
|
||||
|
||||
Find the React page in thrillwiki-87:
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
|
||||
```
|
||||
|
||||
Key pages:
|
||||
- `Index.tsx` - Homepage
|
||||
- `Parks.tsx` - Parks listing
|
||||
- `ParkDetail.tsx` - Individual park (36KB - complex)
|
||||
- `Rides.tsx` - Rides listing
|
||||
- `RideDetail.tsx` - Individual ride (54KB - most complex)
|
||||
- `Profile.tsx` - User profile (51KB - complex)
|
||||
- `Search.tsx` - Global search
|
||||
- `AdminDashboard.tsx` - Admin panel
|
||||
|
||||
## Step 2: Analyze Page Structure
|
||||
|
||||
Extract from React page:
|
||||
|
||||
```tsx
|
||||
// Route params
|
||||
const { id } = useParams()
|
||||
|
||||
// Data fetching
|
||||
const { data, isLoading, error } = useQuery(...)
|
||||
|
||||
// SEO
|
||||
// (usually react-helmet or similar)
|
||||
|
||||
// Page layout structure
|
||||
return (
|
||||
<Layout>
|
||||
<Tabs>...</Tabs>
|
||||
<Content>...</Content>
|
||||
</Layout>
|
||||
)
|
||||
```
|
||||
|
||||
## Step 3: Create Nuxt Page
|
||||
|
||||
### Route Params
|
||||
```tsx
|
||||
// React
|
||||
const { parkSlug } = useParams()
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const parkSlug = route.params.park_slug as string
|
||||
</script>
|
||||
```
|
||||
|
||||
### Data Fetching
|
||||
```tsx
|
||||
// React (React Query)
|
||||
const { data, isLoading } = useQuery(['park', id], () => fetchPark(id))
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<script setup lang="ts">
|
||||
const { data, pending } = await useAsyncData(
|
||||
`park-${parkSlug}`,
|
||||
() => useParksApi().getPark(parkSlug)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
### SEO Meta
|
||||
```tsx
|
||||
// React (Helmet)
|
||||
<Helmet>
|
||||
<title>{park.name} | ThrillWiki</title>
|
||||
</Helmet>
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: () => `${data.value?.name} | ThrillWiki`,
|
||||
description: () => data.value?.description
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Page Meta
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth'], // if authentication required
|
||||
layout: 'default'
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Step 4: Port Page Sections
|
||||
|
||||
Common patterns:
|
||||
|
||||
### Tabs Structure
|
||||
```tsx
|
||||
// React
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">...</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<UTabs :items="tabs" v-model="activeTab">
|
||||
<template #default="{ item }">
|
||||
<component :is="item.component" v-bind="item.props" />
|
||||
</template>
|
||||
</UTabs>
|
||||
```
|
||||
|
||||
### Loading States
|
||||
```tsx
|
||||
// React
|
||||
{isLoading ? <Skeleton /> : <Content />}
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<template>
|
||||
<div v-if="pending">
|
||||
<USkeleton class="h-48" />
|
||||
</div>
|
||||
<div v-else-if="data">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Step 5: Target Location
|
||||
|
||||
Nuxt pages use file-based routing:
|
||||
|
||||
| React Route | Nuxt File Path |
|
||||
|-------------|----------------|
|
||||
| `/parks` | `pages/parks/index.vue` |
|
||||
| `/parks/:slug` | `pages/parks/[park_slug]/index.vue` |
|
||||
| `/parks/:slug/rides/:ride` | `pages/parks/[park_slug]/rides/[ride_slug].vue` |
|
||||
| `/manufacturers/:id` | `pages/manufacturers/[slug].vue` |
|
||||
|
||||
## Step 6: Verify Feature Parity
|
||||
|
||||
Compare source page with target:
|
||||
- [ ] All tabs/sections present
|
||||
- [ ] All data displayed
|
||||
- [ ] All actions work (edit, delete, etc.)
|
||||
- [ ] Responsive layout matches
|
||||
- [ ] Loading states present
|
||||
- [ ] Error handling works
|
||||
- [ ] SEO meta correct
|
||||
|
||||
## Reference: Page Complexity
|
||||
|
||||
| Page | Source Size | Priority |
|
||||
|------|-------------|----------|
|
||||
| RideDetail.tsx | 54KB | High |
|
||||
| Profile.tsx | 51KB | High |
|
||||
| AdminSettings.tsx | 44KB | Medium |
|
||||
| ParkDetail.tsx | 36KB | High |
|
||||
| Auth.tsx | 29KB | Medium |
|
||||
| Parks.tsx | 22KB | High |
|
||||
| Rides.tsx | 20KB | High |
|
||||
201
.agent/workflows/migrate-type.md
Normal file
201
.agent/workflows/migrate-type.md
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
description: Port TypeScript types from thrillwiki-87 to the Nuxt project
|
||||
---
|
||||
|
||||
# Migrate Type Workflow
|
||||
|
||||
Port TypeScript type definitions from thrillwiki-87 to the Nuxt 4 project, ensuring sync with Django serializers.
|
||||
|
||||
## Step 1: Locate Source Types
|
||||
|
||||
Find types in thrillwiki-87:
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
|
||||
```
|
||||
|
||||
Key type files (63 total):
|
||||
- `database.ts` - Core entity types (12KB)
|
||||
- `statuses.ts` - Status enums (13KB)
|
||||
- `moderation.ts` - Moderation types (10KB)
|
||||
- `rideAttributes.ts` - Ride specs (8KB)
|
||||
- `versioning.ts` - Version history (7KB)
|
||||
- `submissions.ts` - Submission types
|
||||
- `company.ts` - Company entities
|
||||
|
||||
## Step 2: Analyze Source Types
|
||||
|
||||
Extract type structure:
|
||||
|
||||
```typescript
|
||||
// thrillwiki-87/src/types/database.ts
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
park_type: ParkType
|
||||
status: ParkStatus
|
||||
opening_date?: string
|
||||
closing_date?: string
|
||||
location?: ParkLocation
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface ParkLocation {
|
||||
latitude: number
|
||||
longitude: number
|
||||
country: string
|
||||
city?: string
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Check Django Serializers
|
||||
|
||||
Before porting, verify Django backend has matching fields:
|
||||
|
||||
```bash
|
||||
# Check serializers in:
|
||||
backend/apps/parks/serializers.py
|
||||
backend/apps/rides/serializers.py
|
||||
backend/apps/companies/serializers.py
|
||||
```
|
||||
|
||||
The Django serializer defines what the API returns:
|
||||
```python
|
||||
class ParkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = ['id', 'name', 'slug', 'park_type', 'status', ...]
|
||||
```
|
||||
|
||||
## Step 4: Handle Naming Conventions
|
||||
|
||||
Django uses snake_case, TypeScript typically uses camelCase. Choose one strategy:
|
||||
|
||||
### Option A: Match Django exactly (Recommended)
|
||||
```typescript
|
||||
// Keep snake_case from API
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
park_type: string
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
### Option B: Transform in API layer
|
||||
```typescript
|
||||
// camelCase in types
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
parkType: string
|
||||
createdAt: string
|
||||
}
|
||||
// Transform in useApi or serializer
|
||||
```
|
||||
|
||||
**Current project uses Option A (snake_case).**
|
||||
|
||||
## Step 5: Port the Types
|
||||
|
||||
Create/update type files:
|
||||
|
||||
```typescript
|
||||
// frontend/app/types/park.ts
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
park_type: string
|
||||
status: string
|
||||
short_description?: string
|
||||
description?: string
|
||||
opening_date?: string
|
||||
closing_date?: string
|
||||
operating_season?: string
|
||||
website?: string
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
// Match Django serializer fields exactly
|
||||
}
|
||||
|
||||
export interface ParkDetail extends Park {
|
||||
rides?: Ride[]
|
||||
reviews?: Review[]
|
||||
photos?: Photo[]
|
||||
// Extended fields from detail serializer
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Port Enums/Options
|
||||
|
||||
Source may have enums that should be constants:
|
||||
|
||||
```typescript
|
||||
// thrillwiki-87/src/types/statuses.ts
|
||||
export const PARK_STATUS = {
|
||||
OPERATING: 'operating',
|
||||
CLOSED: 'closed',
|
||||
// ...
|
||||
} as const
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// frontend/app/utils/constants.ts
|
||||
export const PARK_STATUS_OPTIONS = [
|
||||
{ value: 'operating', label: 'Operating', color: 'green' },
|
||||
{ value: 'closed', label: 'Closed', color: 'red' },
|
||||
// Sync with backend/apps/parks/choices.py
|
||||
]
|
||||
```
|
||||
|
||||
## Step 7: Target Locations
|
||||
|
||||
```
|
||||
frontend/app/types/
|
||||
├── index.ts # Re-exports and common types
|
||||
├── park.ts # Park types
|
||||
├── ride.ts # Ride types
|
||||
├── company.ts # Company types (manufacturer, designer, etc.)
|
||||
├── user.ts # User/profile types
|
||||
├── moderation.ts # Moderation types
|
||||
└── api.ts # API response wrappers
|
||||
```
|
||||
|
||||
## Step 8: Verify Type Sync
|
||||
|
||||
| Layer | File | Must Match |
|
||||
|-------|------|------------|
|
||||
| Django Model | `backend/apps/*/models.py` | Database schema |
|
||||
| Serializer | `backend/apps/*/serializers.py` | API response |
|
||||
| Frontend Type | `frontend/app/types/*.ts` | Serializer fields |
|
||||
|
||||
Run checks:
|
||||
```bash
|
||||
# Check Django fields
|
||||
python manage.py shell -c "from apps.parks.models import Park; print([f.name for f in Park._meta.fields])"
|
||||
|
||||
# Check serializer fields
|
||||
python manage.py shell -c "from apps.parks.serializers import ParkSerializer; print(ParkSerializer().fields.keys())"
|
||||
```
|
||||
|
||||
## Step 9: Checklist
|
||||
|
||||
- [ ] All source type fields ported
|
||||
- [ ] Fields match Django serializer
|
||||
- [ ] snake_case naming used
|
||||
- [ ] Optional fields marked with `?`
|
||||
- [ ] Enums/options in constants.ts
|
||||
- [ ] Backend choices.py synced
|
||||
- [ ] Types exported from index.ts
|
||||
|
||||
## Priority Types
|
||||
|
||||
| Type File | Source Size | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| statuses.ts | 13KB | All status enums |
|
||||
| database.ts | 12KB | Core entities |
|
||||
| moderation.ts | 10KB | Queue types |
|
||||
| rideAttributes.ts | 8KB | Spec options |
|
||||
| versioning.ts | 7KB | History types |
|
||||
193
.agent/workflows/migrate.md
Normal file
193
.agent/workflows/migrate.md
Normal file
@@ -0,0 +1,193 @@
|
||||
---
|
||||
description: Audit gaps and implement missing features from thrillwiki-87 source
|
||||
---
|
||||
|
||||
# Migration Workflow
|
||||
|
||||
**thrillwiki-87 is LAW.** This workflow audits what's missing and implements it.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run `/migrate` to:
|
||||
1. Audit current project against thrillwiki-87
|
||||
2. Identify the highest-priority missing feature
|
||||
3. Implement it using the appropriate sub-workflow
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Gap Analysis
|
||||
|
||||
### Step 1.1: Audit Components
|
||||
|
||||
Compare React components with Vue components:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/components/
|
||||
```
|
||||
|
||||
For each React component directory, check if equivalent Vue component exists:
|
||||
- **EXISTS**: Mark as ✅, check for feature parity
|
||||
- **MISSING**: Mark as ❌, add to implementation queue
|
||||
|
||||
### Step 1.2: Audit Pages
|
||||
|
||||
Compare React pages with Nuxt pages:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/pages/
|
||||
```
|
||||
|
||||
Key pages to audit (by size/complexity):
|
||||
| React Page | Size | Priority |
|
||||
|------------|------|----------|
|
||||
| RideDetail.tsx | 54KB | P0 |
|
||||
| Profile.tsx | 51KB | P0 |
|
||||
| AdminSettings.tsx | 44KB | P1 |
|
||||
| ParkDetail.tsx | 36KB | P0 |
|
||||
| Auth.tsx | 29KB | P1 |
|
||||
| Parks.tsx | 22KB | P0 |
|
||||
| Rides.tsx | 20KB | P0 |
|
||||
|
||||
### Step 1.3: Audit Hooks → Composables
|
||||
|
||||
Compare React hooks with Vue composables:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/composables/
|
||||
```
|
||||
|
||||
Priority hooks:
|
||||
| React Hook | Size | Current Status |
|
||||
|------------|------|----------------|
|
||||
| useModerationQueue.ts | 21KB | Check useModeration.ts |
|
||||
| useEntityVersions.ts | 14KB | ❌ Missing |
|
||||
| useAuth.tsx | 11KB | Check useAuth.ts |
|
||||
| useRideCreditFilters.ts | 9KB | ❌ Missing |
|
||||
|
||||
### Step 1.4: Audit Types
|
||||
|
||||
Compare TypeScript definitions:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/types/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Priority Selection
|
||||
|
||||
After auditing, select the highest priority gap:
|
||||
|
||||
### Priority Matrix
|
||||
|
||||
| Category | Weight | Examples |
|
||||
|----------|--------|----------|
|
||||
| **P0 - Core UX** | 10 | Main entity pages, search, auth |
|
||||
| **P1 - Features** | 7 | Reviews, credits, lists |
|
||||
| **P2 - Admin** | 5 | Moderation, settings |
|
||||
| **P3 - Polish** | 3 | Animations, edge cases |
|
||||
|
||||
Select ONE item to implement this session.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Implementation
|
||||
|
||||
Based on the gap type, use the appropriate sub-workflow:
|
||||
|
||||
### For Missing Component
|
||||
```
|
||||
/migrate-component
|
||||
```
|
||||
|
||||
### For Missing Page
|
||||
```
|
||||
/migrate-page
|
||||
```
|
||||
|
||||
### For Missing Hook/Composable
|
||||
```
|
||||
/migrate-hook
|
||||
```
|
||||
|
||||
### For Missing Types
|
||||
```
|
||||
/migrate-type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Verification
|
||||
|
||||
After implementation:
|
||||
|
||||
1. **Feature Parity Check**: Does it match thrillwiki-87 behavior?
|
||||
2. **Visual Parity Check**: Does it look the same?
|
||||
3. **Data Parity Check**: Does it show the same information?
|
||||
4. **Interaction Parity Check**: Do actions work the same way?
|
||||
|
||||
---
|
||||
|
||||
## Gap Tracking
|
||||
|
||||
Update `GAP_ANALYSIS_MATRIX.md` with status:
|
||||
|
||||
```markdown
|
||||
| Feature | Source Location | Target Location | Status |
|
||||
|---------|-----------------|-----------------|--------|
|
||||
| Park Detail Tabs | src/pages/ParkDetail.tsx | pages/parks/[park_slug]/ | [OK] |
|
||||
| Entity Versioning | src/hooks/useEntityVersions.ts | composables/ | [MISSING] |
|
||||
| Ride Credits | src/components/credits/ | components/credits/ | [PARTIAL] |
|
||||
```
|
||||
|
||||
Status tags:
|
||||
- `[OK]` - Feature parity achieved
|
||||
- `[PARTIAL]` - Some features missing
|
||||
- `[MISSING]` - Not implemented
|
||||
- `[BLOCKED]` - Waiting on backend
|
||||
|
||||
---
|
||||
|
||||
## Reference: Source Documentation
|
||||
|
||||
Always check thrillwiki-87 docs for specifications:
|
||||
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/docs/
|
||||
├── SITE_OVERVIEW.md # What features exist
|
||||
├── COMPONENTS.md # Component specifications
|
||||
├── PAGES.md # Page layouts (72KB - comprehensive)
|
||||
├── USER_FLOWS.md # Interaction patterns (77KB)
|
||||
├── DESIGN_SYSTEM.md # Visual standards
|
||||
└── [Many more...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Single Command Execution
|
||||
|
||||
When you run `/migrate`, execute these steps:
|
||||
|
||||
1. **Read GAP_ANALYSIS_MATRIX.md** to see current status
|
||||
2. **List directories** in both projects to find new gaps
|
||||
3. **Select highest priority** missing item
|
||||
4. **Read source implementation** from thrillwiki-87
|
||||
5. **Implement in target** following sub-workflow patterns
|
||||
6. **Update GAP_ANALYSIS_MATRIX.md** with new status
|
||||
7. **Report what was implemented** and next priority item
|
||||
472
.agent/workflows/moderation.md
Normal file
472
.agent/workflows/moderation.md
Normal file
@@ -0,0 +1,472 @@
|
||||
---
|
||||
description: Add moderation support to a content type in ThrillWiki
|
||||
---
|
||||
|
||||
# Moderation Workflow
|
||||
|
||||
Add moderation (submission queue, version history, approval flow) to a content type.
|
||||
|
||||
## Overview
|
||||
|
||||
ThrillWiki's moderation system ensures quality by:
|
||||
1. User submits new/edited content → Creates `Submission` record
|
||||
2. Content enters moderation queue with `pending` status
|
||||
3. Moderator reviews and approves/rejects
|
||||
4. On approval → Content is published, version record created
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Ensure Model Supports Versioning
|
||||
|
||||
The content model needs to track its current state and history:
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/models.py
|
||||
|
||||
class Park(BaseModel):
|
||||
"""Main park model - always shows current approved data"""
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
# ... other fields
|
||||
|
||||
# Track the current approved version
|
||||
current_version = models.ForeignKey(
|
||||
'ParkVersion',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='current_for'
|
||||
)
|
||||
|
||||
|
||||
class ParkVersion(BaseModel):
|
||||
"""Immutable snapshot of park data at a point in time"""
|
||||
park = models.ForeignKey(
|
||||
Park,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='versions'
|
||||
)
|
||||
# Store complete snapshot of editable fields
|
||||
data = models.JSONField()
|
||||
|
||||
# Metadata
|
||||
changed_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True
|
||||
)
|
||||
change_summary = models.CharField(max_length=255, blank=True)
|
||||
submission = models.ForeignKey(
|
||||
'submissions.Submission',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='versions'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def apply_to_park(self):
|
||||
"""Apply this version's data to the parent park"""
|
||||
for field, value in self.data.items():
|
||||
if hasattr(self.park, field):
|
||||
setattr(self.park, field, value)
|
||||
self.park.current_version = self
|
||||
self.park.save()
|
||||
```
|
||||
|
||||
### Step 2: Create Submission Serializers
|
||||
|
||||
```python
|
||||
# backend/apps/submissions/serializers.py
|
||||
|
||||
class ParkSubmissionSerializer(serializers.Serializer):
|
||||
"""Serializer for park submission data"""
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
city = serializers.CharField(max_length=100)
|
||||
country = serializers.CharField(max_length=100)
|
||||
status = serializers.ChoiceField(choices=Park.Status.choices)
|
||||
# ... other editable fields
|
||||
|
||||
def validate_name(self, value):
|
||||
# Custom validation if needed
|
||||
return value
|
||||
|
||||
|
||||
class SubmissionCreateSerializer(serializers.ModelSerializer):
|
||||
"""Create a new submission"""
|
||||
data = serializers.JSONField()
|
||||
|
||||
class Meta:
|
||||
model = Submission
|
||||
fields = ['content_type', 'object_id', 'data', 'change_summary']
|
||||
|
||||
def validate(self, attrs):
|
||||
content_type = attrs['content_type']
|
||||
|
||||
# Get the appropriate serializer for this content type
|
||||
serializer_map = {
|
||||
'park': ParkSubmissionSerializer,
|
||||
'ride': RideSubmissionSerializer,
|
||||
# ... other content types
|
||||
}
|
||||
|
||||
serializer_class = serializer_map.get(content_type)
|
||||
if not serializer_class:
|
||||
raise serializers.ValidationError(
|
||||
{'content_type': 'Unsupported content type'}
|
||||
)
|
||||
|
||||
# Validate the data field
|
||||
data_serializer = serializer_class(data=attrs['data'])
|
||||
data_serializer.is_valid(raise_exception=True)
|
||||
attrs['data'] = data_serializer.validated_data
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['submitted_by'] = self.context['request'].user
|
||||
validated_data['status'] = Submission.Status.PENDING
|
||||
return super().create(validated_data)
|
||||
```
|
||||
|
||||
### Step 3: Create Submission ViewSet
|
||||
|
||||
```python
|
||||
# backend/apps/submissions/views.py
|
||||
|
||||
class SubmissionViewSet(viewsets.ModelViewSet):
|
||||
"""API for content submissions"""
|
||||
serializer_class = SubmissionSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
# Users see their own submissions
|
||||
# Moderators see all pending submissions
|
||||
if user.is_moderator:
|
||||
return Submission.objects.all()
|
||||
return Submission.objects.filter(submitted_by=user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return SubmissionCreateSerializer
|
||||
return SubmissionSerializer
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def diff(self, request, pk=None):
|
||||
"""Get diff between submission and current version"""
|
||||
submission = self.get_object()
|
||||
|
||||
if submission.object_id:
|
||||
# Edit submission - compare to current
|
||||
current = self.get_current_data(submission)
|
||||
return Response({
|
||||
'before': current,
|
||||
'after': submission.data,
|
||||
'changes': self.compute_diff(current, submission.data)
|
||||
})
|
||||
else:
|
||||
# New submission - no comparison
|
||||
return Response({
|
||||
'before': None,
|
||||
'after': submission.data,
|
||||
'changes': None
|
||||
})
|
||||
```
|
||||
|
||||
### Step 4: Create Moderation ViewSet
|
||||
|
||||
```python
|
||||
# backend/apps/moderation/views.py
|
||||
|
||||
class ModerationViewSet(viewsets.ViewSet):
|
||||
"""Moderation queue and actions"""
|
||||
permission_classes = [IsModerator]
|
||||
|
||||
def list(self, request):
|
||||
"""Get moderation queue"""
|
||||
queryset = Submission.objects.filter(
|
||||
status=Submission.Status.PENDING
|
||||
).select_related(
|
||||
'submitted_by'
|
||||
).order_by('created_at')
|
||||
|
||||
# Filter by content type
|
||||
content_type = request.query_params.get('type')
|
||||
if content_type:
|
||||
queryset = queryset.filter(content_type=content_type)
|
||||
|
||||
serializer = SubmissionSerializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve(self, request, pk=None):
|
||||
"""Approve a submission"""
|
||||
submission = get_object_or_404(Submission, pk=pk)
|
||||
|
||||
if submission.status != Submission.Status.PENDING:
|
||||
return Response(
|
||||
{'error': 'Submission is not pending'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Apply the submission
|
||||
with transaction.atomic():
|
||||
if submission.object_id:
|
||||
# Edit existing content
|
||||
content = self.get_content_object(submission)
|
||||
version = self.create_version(content, submission)
|
||||
version.apply_to_park()
|
||||
else:
|
||||
# Create new content
|
||||
content = self.create_content(submission)
|
||||
version = self.create_version(content, submission)
|
||||
content.current_version = version
|
||||
content.save()
|
||||
|
||||
submission.status = Submission.Status.APPROVED
|
||||
submission.reviewed_by = request.user
|
||||
submission.reviewed_at = timezone.now()
|
||||
submission.save()
|
||||
|
||||
# Notify user
|
||||
notify_user(
|
||||
submission.submitted_by,
|
||||
'submission_approved',
|
||||
{'submission': submission}
|
||||
)
|
||||
|
||||
return Response({'status': 'approved'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject(self, request, pk=None):
|
||||
"""Reject a submission"""
|
||||
submission = get_object_or_404(Submission, pk=pk)
|
||||
|
||||
submission.status = Submission.Status.REJECTED
|
||||
submission.reviewed_by = request.user
|
||||
submission.reviewed_at = timezone.now()
|
||||
submission.review_notes = request.data.get('notes', '')
|
||||
submission.save()
|
||||
|
||||
# Notify user
|
||||
notify_user(
|
||||
submission.submitted_by,
|
||||
'submission_rejected',
|
||||
{'submission': submission, 'reason': submission.review_notes}
|
||||
)
|
||||
|
||||
return Response({'status': 'rejected'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def request_changes(self, request, pk=None):
|
||||
"""Request changes to a submission"""
|
||||
submission = get_object_or_404(Submission, pk=pk)
|
||||
|
||||
submission.status = Submission.Status.CHANGES_REQUESTED
|
||||
submission.reviewed_by = request.user
|
||||
submission.review_notes = request.data.get('notes', '')
|
||||
submission.save()
|
||||
|
||||
# Notify user
|
||||
notify_user(
|
||||
submission.submitted_by,
|
||||
'submission_changes_requested',
|
||||
{'submission': submission, 'notes': submission.review_notes}
|
||||
)
|
||||
|
||||
return Response({'status': 'changes_requested'})
|
||||
```
|
||||
|
||||
### Step 5: Frontend - Submission Form
|
||||
|
||||
```vue
|
||||
<!-- frontend/components/forms/ParkSubmitForm.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Park } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
park?: Park // Existing park for edits, null for new
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
name: props.park?.name || '',
|
||||
description: props.park?.description || '',
|
||||
city: props.park?.city || '',
|
||||
country: props.park?.country || '',
|
||||
status: props.park?.status || 'operating',
|
||||
changeSummary: '',
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
|
||||
async function handleSubmit() {
|
||||
isSubmitting.value = true
|
||||
errors.value = {}
|
||||
|
||||
try {
|
||||
await $fetch('/api/v1/submissions/', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
content_type: 'park',
|
||||
object_id: props.park?.id || null,
|
||||
data: {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
city: form.city,
|
||||
country: form.country,
|
||||
status: form.status,
|
||||
},
|
||||
change_summary: form.changeSummary,
|
||||
}
|
||||
})
|
||||
|
||||
// Show success message
|
||||
useToast().success(
|
||||
props.park
|
||||
? 'Your edit has been submitted for review'
|
||||
: 'Your submission has been received'
|
||||
)
|
||||
|
||||
emit('submitted')
|
||||
} catch (e: any) {
|
||||
if (e.data?.error?.details) {
|
||||
errors.value = e.data.error.details
|
||||
} else {
|
||||
useToast().error('Failed to submit. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<FormField label="Park Name" :error="errors.name?.[0]" required>
|
||||
<Input v-model="form.name" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" :error="errors.description?.[0]">
|
||||
<Textarea v-model="form.description" rows="4" />
|
||||
</FormField>
|
||||
|
||||
<!-- More fields... -->
|
||||
|
||||
<FormField
|
||||
label="Summary of Changes"
|
||||
:error="errors.changeSummary?.[0]"
|
||||
hint="Briefly describe what you're adding or changing"
|
||||
>
|
||||
<Input v-model="form.changeSummary" />
|
||||
</FormField>
|
||||
|
||||
<Alert variant="info">
|
||||
Your submission will be reviewed by our moderators before being published.
|
||||
</Alert>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
|
||||
<Button type="submit" :loading="isSubmitting">
|
||||
{{ park ? 'Submit Edit' : 'Submit for Review' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Step 6: Frontend - Moderation Queue Page
|
||||
|
||||
```vue
|
||||
<!-- frontend/pages/moderation/index.vue -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'moderator']
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Moderation Queue | ThrillWiki'
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
const { data, pending, refresh } = await useAsyncData(
|
||||
'moderation-queue',
|
||||
() => $fetch('/api/v1/moderation/', { params: filters }),
|
||||
{ watch: [filters] }
|
||||
)
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
await $fetch(`/api/v1/moderation/${id}/approve/`, { method: 'POST' })
|
||||
useToast().success('Submission approved')
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function handleReject(id: string, notes: string) {
|
||||
await $fetch(`/api/v1/moderation/${id}/reject/`, {
|
||||
method: 'POST',
|
||||
body: { notes }
|
||||
})
|
||||
useToast().success('Submission rejected')
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<h1 class="text-3xl font-bold mb-8">Moderation Queue</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<Select v-model="filters.type">
|
||||
<SelectOption value="">All Types</SelectOption>
|
||||
<SelectOption value="park">Parks</SelectOption>
|
||||
<SelectOption value="ride">Rides</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Queue -->
|
||||
<div class="space-y-4">
|
||||
<SubmissionCard
|
||||
v-for="submission in data"
|
||||
:key="submission.id"
|
||||
:submission="submission"
|
||||
@approve="handleApprove(submission.id)"
|
||||
@reject="notes => handleReject(submission.id, notes)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="!pending && !data?.length"
|
||||
icon="CheckCircle"
|
||||
title="Queue is empty"
|
||||
description="No pending submissions to review"
|
||||
/>
|
||||
</PageContainer>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Model supports versioning with JSONField snapshot
|
||||
- [ ] Submission model tracks all submission states
|
||||
- [ ] Validation serializers exist for each content type
|
||||
- [ ] Moderation endpoints have proper permissions
|
||||
- [ ] Approval creates version and applies changes atomically
|
||||
- [ ] Users are notified of submission status changes
|
||||
- [ ] Frontend shows submission status to users
|
||||
- [ ] Moderation queue is filterable and efficient
|
||||
- [ ] Diff view shows before/after comparison
|
||||
- [ ] Tests cover approval, rejection, and edge cases
|
||||
360
.agent/workflows/new-api.md
Normal file
360
.agent/workflows/new-api.md
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
description: Create a new Django REST API endpoint following ThrillWiki conventions
|
||||
---
|
||||
|
||||
# New API Workflow
|
||||
|
||||
Create a new Django REST Framework API endpoint following ThrillWiki's patterns.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
1. **Resource Name**: What entity is this API for? (e.g., Park, Ride, Review)
|
||||
2. **Operations**: Which CRUD operations are needed?
|
||||
- [ ] List (GET /resources/)
|
||||
- [ ] Create (POST /resources/)
|
||||
- [ ] Retrieve (GET /resources/{id}/)
|
||||
- [ ] Update (PUT/PATCH /resources/{id}/)
|
||||
- [ ] Delete (DELETE /resources/{id}/)
|
||||
3. **Permissions**: Who can access?
|
||||
- Public read, authenticated write?
|
||||
- Owner only?
|
||||
- Moderator/Admin only?
|
||||
4. **Filtering**: What filter options are needed?
|
||||
5. **Nested Resources**: Does this belong under another resource?
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Create or Update the Model
|
||||
|
||||
File: `backend/apps/[app]/models.py`
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""A resource description"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = 'draft', 'Draft'
|
||||
PUBLISHED = 'published', 'Published'
|
||||
ARCHIVED = 'archived', 'Archived'
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.DRAFT
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='resources'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', '-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
### 2. Create the Serializer
|
||||
|
||||
File: `backend/apps/[app]/serializers.py`
|
||||
|
||||
```python
|
||||
from rest_framework import serializers
|
||||
from .models import Resource
|
||||
|
||||
class ResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Resource listing"""
|
||||
owner_username = serializers.CharField(source='owner.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'description', 'status',
|
||||
'owner', 'owner_username',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'slug', 'owner', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class ResourceDetailSerializer(ResourceSerializer):
|
||||
"""Extended serializer for single resource view"""
|
||||
# Add related objects for detail view
|
||||
related_items = RelatedItemSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(ResourceSerializer.Meta):
|
||||
fields = ResourceSerializer.Meta.fields + ['related_items']
|
||||
|
||||
|
||||
class ResourceCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating resources"""
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ['name', 'description', 'status']
|
||||
|
||||
def create(self, validated_data):
|
||||
# Auto-generate slug
|
||||
validated_data['slug'] = slugify(validated_data['name'])
|
||||
# Set owner from request
|
||||
validated_data['owner'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
```
|
||||
|
||||
### 3. Create the ViewSet
|
||||
|
||||
File: `backend/apps/[app]/views.py`
|
||||
|
||||
```python
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
|
||||
from .models import Resource
|
||||
from .serializers import (
|
||||
ResourceSerializer,
|
||||
ResourceDetailSerializer,
|
||||
ResourceCreateSerializer
|
||||
)
|
||||
from .filters import ResourceFilter
|
||||
from .permissions import IsOwnerOrReadOnly
|
||||
|
||||
|
||||
class ResourceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for resources.
|
||||
|
||||
list: Get all resources (with filtering)
|
||||
create: Create a new resource (authenticated)
|
||||
retrieve: Get a single resource
|
||||
update: Update a resource (owner only)
|
||||
destroy: Delete a resource (owner only)
|
||||
"""
|
||||
queryset = Resource.objects.all()
|
||||
permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
|
||||
lookup_field = 'slug'
|
||||
|
||||
# Filtering
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = ResourceFilter
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'created_at', 'updated_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optimize queries"""
|
||||
return Resource.objects.select_related(
|
||||
'owner'
|
||||
).prefetch_related(
|
||||
'related_items'
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use different serializers for different actions"""
|
||||
if self.action == 'create':
|
||||
return ResourceCreateSerializer
|
||||
if self.action == 'retrieve':
|
||||
return ResourceDetailSerializer
|
||||
return ResourceSerializer
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def publish(self, request, slug=None):
|
||||
"""Publish a draft resource"""
|
||||
resource = self.get_object()
|
||||
if resource.status != Resource.Status.DRAFT:
|
||||
return Response(
|
||||
{'error': 'Only draft resources can be published'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
resource.status = Resource.Status.PUBLISHED
|
||||
resource.save()
|
||||
return Response(ResourceSerializer(resource).data)
|
||||
```
|
||||
|
||||
### 4. Create Custom Filter
|
||||
|
||||
File: `backend/apps/[app]/filters.py`
|
||||
|
||||
```python
|
||||
import django_filters
|
||||
from .models import Resource
|
||||
|
||||
|
||||
class ResourceFilter(django_filters.FilterSet):
|
||||
"""Filters for Resource API"""
|
||||
|
||||
status = django_filters.ChoiceFilter(choices=Resource.Status.choices)
|
||||
owner = django_filters.CharFilter(field_name='owner__username')
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name='created_at',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name='created_at',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ['status', 'owner']
|
||||
```
|
||||
|
||||
### 5. Create Custom Permission
|
||||
|
||||
File: `backend/apps/[app]/permissions.py`
|
||||
|
||||
```python
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
|
||||
class IsOwnerOrReadOnly(BasePermission):
|
||||
"""Allow read to all, write only to owner"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return obj.owner == request.user
|
||||
|
||||
|
||||
class IsModerator(BasePermission):
|
||||
"""Allow access only to moderators"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return (
|
||||
request.user.is_authenticated and
|
||||
request.user.is_moderator
|
||||
)
|
||||
```
|
||||
|
||||
### 6. Register URLs
|
||||
|
||||
File: `backend/apps/[app]/urls.py`
|
||||
|
||||
```python
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ResourceViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('resources', ResourceViewSet, basename='resource')
|
||||
|
||||
urlpatterns = router.urls
|
||||
```
|
||||
|
||||
Add to main urls:
|
||||
```python
|
||||
# backend/config/urls.py
|
||||
urlpatterns = [
|
||||
...
|
||||
path('api/v1/', include('apps.app_name.urls')),
|
||||
]
|
||||
```
|
||||
|
||||
### 7. Create Migration
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations app_name
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### 8. Add Tests
|
||||
|
||||
File: `backend/apps/[app]/tests/test_views.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.users.factories import UserFactory
|
||||
from .factories import ResourceFactory
|
||||
|
||||
|
||||
class TestResourceAPI(APITestCase):
|
||||
"""Tests for Resource API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = UserFactory()
|
||||
self.resource = ResourceFactory(owner=self.user)
|
||||
|
||||
def test_list_resources_unauthenticated(self):
|
||||
"""Anonymous users can list resources"""
|
||||
url = reverse('resource-list')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_create_resource_authenticated(self):
|
||||
"""Authenticated users can create resources"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
url = reverse('resource-list')
|
||||
data = {'name': 'New Resource', 'description': 'Test'}
|
||||
response = self.client.post(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_create_resource_unauthenticated(self):
|
||||
"""Anonymous users cannot create resources"""
|
||||
url = reverse('resource-list')
|
||||
data = {'name': 'New Resource'}
|
||||
response = self.client.post(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_update_own_resource(self):
|
||||
"""Users can update their own resources"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
url = reverse('resource-detail', args=[self.resource.slug])
|
||||
response = self.client.patch(url, {'name': 'Updated'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_update_others_resource(self):
|
||||
"""Users cannot update others' resources"""
|
||||
other_user = UserFactory()
|
||||
self.client.force_authenticate(user=other_user)
|
||||
url = reverse('resource-detail', args=[self.resource.slug])
|
||||
response = self.client.patch(url, {'name': 'Hacked'})
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
After creating the API:
|
||||
|
||||
- [ ] Model has proper fields and constraints
|
||||
- [ ] Serializers validate input correctly
|
||||
- [ ] ViewSet has proper permissions
|
||||
- [ ] Queries are optimized (select_related, prefetch_related)
|
||||
- [ ] Filtering, search, and ordering work
|
||||
- [ ] Pagination is enabled
|
||||
- [ ] URLs are registered
|
||||
- [ ] Migrations are created and applied
|
||||
- [ ] Tests pass
|
||||
|
||||
## Output
|
||||
|
||||
Report what was created:
|
||||
```
|
||||
Created API: /api/v1/resources/
|
||||
Methods: GET (list), POST (create), GET (detail), PATCH (update), DELETE
|
||||
Permissions: IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly
|
||||
Filters: status, owner, created_after, created_before
|
||||
Search: name, description
|
||||
Files:
|
||||
- backend/apps/[app]/models.py
|
||||
- backend/apps/[app]/serializers.py
|
||||
- backend/apps/[app]/views.py
|
||||
- backend/apps/[app]/filters.py
|
||||
- backend/apps/[app]/permissions.py
|
||||
- backend/apps/[app]/urls.py
|
||||
- backend/apps/[app]/tests/test_views.py
|
||||
```
|
||||
279
.agent/workflows/new-component.md
Normal file
279
.agent/workflows/new-component.md
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
description: Create a new Vue component following ThrillWiki patterns
|
||||
---
|
||||
|
||||
# New Component Workflow
|
||||
|
||||
Create a new Vue component following ThrillWiki's design system and conventions.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
1. **Component Name**: PascalCase (e.g., `ParkCard`, `RatingDisplay`)
|
||||
2. **Category**:
|
||||
- `ui/` - Base components (Button, Card, Input)
|
||||
- `entity/` - Domain-specific (ParkCard, RideCard)
|
||||
- `forms/` - Form components
|
||||
- `specialty/` - Complex/unique components
|
||||
3. **Props**: What data does it receive?
|
||||
4. **Emits**: What events does it emit?
|
||||
5. **State**: Does it have internal state?
|
||||
6. **Variants**: Does it need multiple variants/sizes?
|
||||
|
||||
## Component Template
|
||||
|
||||
### Base Component Structure
|
||||
|
||||
Location: `frontend/components/[category]/ComponentName.vue`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 1. Import types
|
||||
import type { ComponentProps } from '~/types'
|
||||
|
||||
// 2. Define props with TypeScript
|
||||
const props = withDefaults(defineProps<{
|
||||
// Required props
|
||||
title: string
|
||||
// Optional props with defaults
|
||||
variant?: 'default' | 'compact' | 'expanded'
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
// 3. Define emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'select', value: string): void
|
||||
}>()
|
||||
|
||||
// 4. Use composables
|
||||
const { formatDistance } = useUnits()
|
||||
|
||||
// 5. Internal state
|
||||
const isOpen = ref(false)
|
||||
|
||||
// 6. Computed properties
|
||||
const computedClass = computed(() => ({
|
||||
'opacity-50 pointer-events-none': props.disabled,
|
||||
'p-4': props.variant === 'default',
|
||||
'p-2': props.variant === 'compact',
|
||||
}))
|
||||
|
||||
// 7. Methods
|
||||
function handleClick() {
|
||||
if (!props.disabled) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="computedClass" @click="handleClick">
|
||||
<!-- Component content -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Entity Card Component
|
||||
|
||||
For ParkCard, RideCard, etc.:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { Park } from '~/types'
|
||||
import { MapPin, Star } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
park: Park
|
||||
variant?: 'default' | 'compact'
|
||||
}>()
|
||||
|
||||
const statusVariant = computed(() => {
|
||||
switch (props.park.status) {
|
||||
case 'operating': return 'success'
|
||||
case 'closed': return 'destructive'
|
||||
case 'construction': return 'warning'
|
||||
default: return 'default'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`/parks/${park.slug}`">
|
||||
<Card interactive class="overflow-hidden">
|
||||
<!-- Image -->
|
||||
<div class="aspect-video relative">
|
||||
<NuxtImg
|
||||
:src="park.image || '/placeholder-park.jpg'"
|
||||
:alt="park.name"
|
||||
class="object-cover w-full h-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
<Badge
|
||||
:variant="statusVariant"
|
||||
class="absolute top-2 right-2"
|
||||
>
|
||||
{{ park.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg line-clamp-1">
|
||||
{{ park.name }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-muted-foreground flex items-center gap-1 mt-1">
|
||||
<MapPin class="w-4 h-4" />
|
||||
{{ park.city }}, {{ park.country }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
🎢 {{ park.rideCount }} rides
|
||||
</span>
|
||||
<RatingDisplay
|
||||
v-if="park.averageRating"
|
||||
:rating="park.averageRating"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Skeleton Loading Component
|
||||
|
||||
Every entity card should have a matching skeleton:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'default' | 'compact'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<Skeleton class="aspect-video rounded-t-lg" />
|
||||
<div class="p-4 space-y-2">
|
||||
<Skeleton class="h-5 w-3/4" />
|
||||
<Skeleton class="h-4 w-1/2" />
|
||||
<div class="flex justify-between mt-3">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
<Skeleton class="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Variant Pattern (using CVA)
|
||||
|
||||
For components with many variants, use class-variance-authority:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
lg: 'h-12 px-6 text-lg',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type Props = VariantProps<typeof buttonVariants> & {
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
disabled: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="buttonVariants({ variant, size })"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<Loader2 v-if="loading" class="w-4 h-4 mr-2 animate-spin" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Composable Integration
|
||||
|
||||
If component needs shared logic, create a composable:
|
||||
|
||||
```typescript
|
||||
// composables/useUnitDisplay.ts
|
||||
export function useUnitDisplay() {
|
||||
const { preferredUnits } = useUserPreferences()
|
||||
|
||||
function formatSpeed(kmh: number): string {
|
||||
if (preferredUnits.value === 'imperial') {
|
||||
return `${Math.round(kmh * 0.621371)} mph`
|
||||
}
|
||||
return `${kmh} km/h`
|
||||
}
|
||||
|
||||
function formatHeight(meters: number): string {
|
||||
if (preferredUnits.value === 'imperial') {
|
||||
return `${Math.round(meters * 3.28084)} ft`
|
||||
}
|
||||
return `${meters} m`
|
||||
}
|
||||
|
||||
return { formatSpeed, formatHeight }
|
||||
}
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
After creating the component:
|
||||
|
||||
- [ ] TypeScript props are properly defined
|
||||
- [ ] Component follows design system (colors, spacing, typography)
|
||||
- [ ] Responsive on all screen sizes
|
||||
- [ ] Handles loading state (if applicable)
|
||||
- [ ] Handles empty state (if applicable)
|
||||
- [ ] Accessible (ARIA labels, keyboard nav)
|
||||
- [ ] Has matching skeleton component (for async data)
|
||||
- [ ] Works in dark mode
|
||||
|
||||
## Output
|
||||
|
||||
Report what was created:
|
||||
```
|
||||
Created: frontend/components/[category]/ComponentName.vue
|
||||
Props: [list of props]
|
||||
Emits: [list of events]
|
||||
Related: [any composables or sub-components]
|
||||
```
|
||||
311
.agent/workflows/new-feature.md
Normal file
311
.agent/workflows/new-feature.md
Normal file
@@ -0,0 +1,311 @@
|
||||
---
|
||||
description: Implement a full-stack feature across Django backend and Nuxt frontend
|
||||
---
|
||||
|
||||
# New Feature Workflow
|
||||
|
||||
Implement a complete feature spanning the Django backend and Nuxt frontend.
|
||||
|
||||
## Planning Phase
|
||||
|
||||
Before writing any code, create an implementation plan:
|
||||
|
||||
### 1. Feature Definition
|
||||
- **Goal**: What problem does this feature solve?
|
||||
- **User Stories**: Who uses it and how?
|
||||
- **Acceptance Criteria**: How do we know it's done?
|
||||
|
||||
### 2. Technical Scope
|
||||
- **Backend Changes**: Models, APIs, permissions
|
||||
- **Frontend Changes**: Pages, components, state
|
||||
- **Data Flow**: How data moves between layers
|
||||
|
||||
### 3. Implementation Order
|
||||
|
||||
Always implement in this order:
|
||||
1. **Database/Models** - Foundation first
|
||||
2. **API Endpoints** - Backend logic
|
||||
3. **Frontend Components** - UI building blocks
|
||||
4. **Frontend Pages** - Assembled views
|
||||
5. **Integration** - Wire it all together
|
||||
6. **Tests** - Verify everything works
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Backend - Models
|
||||
|
||||
```python
|
||||
# Create or modify models
|
||||
# Remember: Inherit from BaseModel, add proper indexes
|
||||
|
||||
class NewFeature(BaseModel):
|
||||
# Fields
|
||||
name = models.CharField(max_length=255)
|
||||
# ... other fields
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
```
|
||||
|
||||
Run migrations:
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### Step 2: Backend - Serializers
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/serializers.py
|
||||
|
||||
class NewFeatureSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NewFeature
|
||||
fields = ['id', 'name', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
class NewFeatureDetailSerializer(NewFeatureSerializer):
|
||||
# Extended fields for detail view
|
||||
related_data = RelatedSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(NewFeatureSerializer.Meta):
|
||||
fields = NewFeatureSerializer.Meta.fields + ['related_data']
|
||||
```
|
||||
|
||||
### Step 3: Backend - API Views
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/views.py
|
||||
|
||||
class NewFeatureViewSet(viewsets.ModelViewSet):
|
||||
queryset = NewFeature.objects.all()
|
||||
serializer_class = NewFeatureSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
return NewFeature.objects.select_related(
|
||||
# Add related models
|
||||
).prefetch_related(
|
||||
# Add many-to-many or reverse relations
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve':
|
||||
return NewFeatureDetailSerializer
|
||||
return NewFeatureSerializer
|
||||
```
|
||||
|
||||
### Step 4: Backend - URLs
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/urls.py
|
||||
router.register('new-features', NewFeatureViewSet, basename='new-feature')
|
||||
```
|
||||
|
||||
### Step 5: Backend - Tests
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/tests/test_new_feature.py
|
||||
|
||||
class TestNewFeatureAPI(APITestCase):
|
||||
def test_list_features(self):
|
||||
response = self.client.get('/api/v1/new-features/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_create_feature_authenticated(self):
|
||||
self.client.force_authenticate(user=self.user)
|
||||
response = self.client.post('/api/v1/new-features/', {'name': 'Test'})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
```
|
||||
|
||||
### Step 6: Frontend - Types
|
||||
|
||||
```typescript
|
||||
// frontend/types/newFeature.ts
|
||||
|
||||
export interface NewFeature {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface NewFeatureDetail extends NewFeature {
|
||||
relatedData: RelatedItem[]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Frontend - Composables
|
||||
|
||||
```typescript
|
||||
// frontend/composables/useNewFeatures.ts
|
||||
|
||||
export function useNewFeatures() {
|
||||
const api = useApi()
|
||||
|
||||
async function getFeatures(params?: Record<string, any>) {
|
||||
return api<PaginatedResponse<NewFeature>>('/new-features/', { params })
|
||||
}
|
||||
|
||||
async function getFeature(id: string) {
|
||||
return api<NewFeatureDetail>(`/new-features/${id}/`)
|
||||
}
|
||||
|
||||
async function createFeature(data: Partial<NewFeature>) {
|
||||
return api<NewFeature>('/new-features/', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
}
|
||||
|
||||
return { getFeatures, getFeature, createFeature }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Frontend - Components
|
||||
|
||||
Create necessary components following component patterns:
|
||||
|
||||
```vue
|
||||
<!-- frontend/components/entity/NewFeatureCard.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { NewFeature } from '~/types'
|
||||
|
||||
defineProps<{
|
||||
feature: NewFeature
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card interactive>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold">{{ feature.name }}</h3>
|
||||
<!-- Additional content -->
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Step 9: Frontend - Pages
|
||||
|
||||
```vue
|
||||
<!-- frontend/pages/new-features/index.vue -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
// middleware: ['auth'], // if needed
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: 'New Features | ThrillWiki',
|
||||
})
|
||||
|
||||
const { data, pending, error } = await useAsyncData('new-features', () =>
|
||||
useNewFeatures().getFeatures()
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<h1 class="text-3xl font-bold mb-8">New Features</h1>
|
||||
|
||||
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Skeleton v-for="i in 6" :key="i" class="h-48" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="data?.results" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<NewFeatureCard
|
||||
v-for="feature in data.results"
|
||||
:key="feature.id"
|
||||
:feature="feature"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmptyState v-else title="No features found" />
|
||||
</PageContainer>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Step 10: Integration Testing
|
||||
|
||||
Test the full flow:
|
||||
|
||||
1. **API Test**: Verify endpoints with curl or API client
|
||||
2. **Component Test**: Test components in isolation
|
||||
3. **E2E Test**: Test complete user journey
|
||||
|
||||
```typescript
|
||||
// frontend/tests/e2e/newFeature.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('user can view new features', async ({ page }) => {
|
||||
await page.goto('/new-features')
|
||||
await expect(page.locator('h1')).toContainText('New Features')
|
||||
})
|
||||
|
||||
test('authenticated user can create feature', async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/auth/login')
|
||||
// ... login steps
|
||||
|
||||
await page.goto('/new-features/create')
|
||||
await page.fill('input[name="name"]', 'Test Feature')
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
await expect(page).toHaveURL(/\/new-features\//)
|
||||
})
|
||||
```
|
||||
|
||||
## Feature Checklist
|
||||
|
||||
### Backend
|
||||
- [ ] Models created with proper fields and indexes
|
||||
- [ ] Migrations created and applied
|
||||
- [ ] Serializers handle validation
|
||||
- [ ] ViewSet has proper permissions
|
||||
- [ ] Queries are optimized
|
||||
- [ ] URLs registered
|
||||
- [ ] Unit tests pass
|
||||
|
||||
### Frontend
|
||||
- [ ] Types defined
|
||||
- [ ] Composables created for API calls
|
||||
- [ ] Components follow design system
|
||||
- [ ] Pages have proper SEO meta
|
||||
- [ ] Loading states implemented
|
||||
- [ ] Error states handled
|
||||
- [ ] Responsive design verified
|
||||
- [ ] Keyboard accessible
|
||||
|
||||
### Integration
|
||||
- [ ] Data flows correctly between backend and frontend
|
||||
- [ ] Authentication/authorization works
|
||||
- [ ] Error handling covers edge cases
|
||||
- [ ] Performance is acceptable
|
||||
|
||||
## Output Summary
|
||||
|
||||
After completing the feature:
|
||||
|
||||
```markdown
|
||||
## Feature: [Feature Name]
|
||||
|
||||
### Backend
|
||||
- Model: `apps/[app]/models.py` - NewFeature
|
||||
- API: `/api/v1/new-features/`
|
||||
- Permissions: [describe]
|
||||
|
||||
### Frontend
|
||||
- Page: `/new-features` (list), `/new-features/[id]` (detail)
|
||||
- Components: NewFeatureCard, NewFeatureForm
|
||||
- Composable: useNewFeatures
|
||||
|
||||
### Tests
|
||||
- Backend: X tests passing
|
||||
- Frontend: X tests passing
|
||||
- E2E: X tests passing
|
||||
|
||||
### Notes
|
||||
- [Any important implementation notes]
|
||||
- [Known limitations]
|
||||
- [Future improvements]
|
||||
```
|
||||
235
.agent/workflows/new-page.md
Normal file
235
.agent/workflows/new-page.md
Normal file
@@ -0,0 +1,235 @@
|
||||
---
|
||||
description: Create a new page in ThrillWiki following project conventions
|
||||
---
|
||||
|
||||
# New Page Workflow
|
||||
|
||||
Create a new page in ThrillWiki following all conventions and patterns.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
Before creating the page, determine:
|
||||
|
||||
1. **Route**: What URL should this page have?
|
||||
2. **Page Type**:
|
||||
- List page (shows multiple items)
|
||||
- Detail page (shows single item)
|
||||
- Form page (create/edit content)
|
||||
- Static page (about, contact, etc.)
|
||||
3. **Data Requirements**: What data does this page need?
|
||||
4. **Authentication**: Public or authenticated only?
|
||||
5. **Related Components**: What existing components can be reused?
|
||||
|
||||
## File Creation Steps
|
||||
|
||||
### 1. Create the Page Component
|
||||
|
||||
Location: `frontend/pages/[route].vue` or `frontend/pages/[folder]/[route].vue`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Define page metadata
|
||||
definePageMeta({
|
||||
// middleware: ['auth'], // If authenticated only
|
||||
// layout: 'admin', // If using special layout
|
||||
})
|
||||
|
||||
// Set page head
|
||||
useSeoMeta({
|
||||
title: 'Page Title | ThrillWiki',
|
||||
description: 'Page description for SEO',
|
||||
})
|
||||
|
||||
// Fetch data
|
||||
const { data, pending, error } = await useAsyncData('unique-key', () =>
|
||||
$fetch('/api/v1/endpoint/')
|
||||
)
|
||||
|
||||
// Handle error
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: error.value.statusCode || 500,
|
||||
message: error.value.message
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<!-- Breadcrumbs (if applicable) -->
|
||||
<Breadcrumbs :items="breadcrumbItems" />
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold">Page Title</h1>
|
||||
<p class="text-muted-foreground mt-2">Page description</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Skeleton v-for="i in 8" :key="i" class="h-64" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else>
|
||||
<!-- Page content here -->
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. For List Pages - Add Filtering
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Filter state from URL
|
||||
const filters = computed(() => ({
|
||||
status: route.query.status as string || '',
|
||||
search: route.query.search as string || '',
|
||||
page: parseInt(route.query.page as string) || 1
|
||||
}))
|
||||
|
||||
// Fetch with filters
|
||||
const { data, pending, refresh } = await useAsyncData(
|
||||
`items-${JSON.stringify(filters.value)}`,
|
||||
() => $fetch('/api/v1/items/', { params: filters.value }),
|
||||
{ watch: [filters] }
|
||||
)
|
||||
|
||||
// Update filters
|
||||
function updateFilter(key: string, value: string) {
|
||||
router.push({
|
||||
query: { ...route.query, [key]: value || undefined, page: 1 }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Filter Bar -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<Input
|
||||
:model-value="filters.search"
|
||||
@update:model-value="updateFilter('search', $event)"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<Select
|
||||
:model-value="filters.status"
|
||||
@update:model-value="updateFilter('status', $event)"
|
||||
>
|
||||
<SelectOption value="">All</SelectOption>
|
||||
<SelectOption value="operating">Operating</SelectOption>
|
||||
<SelectOption value="closed">Closed</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<ItemCard v-for="item in data?.results" :key="item.id" :item="item" />
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
:current-page="filters.page"
|
||||
:total-pages="Math.ceil((data?.count || 0) / 20)"
|
||||
@page-change="updateFilter('page', $event.toString())"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. For Detail Pages - Dynamic Route
|
||||
|
||||
File: `frontend/pages/items/[slug].vue`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: item, error } = await useAsyncData(
|
||||
`item-${slug}`,
|
||||
() => $fetch(`/api/v1/items/${slug}/`)
|
||||
)
|
||||
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Item not found'
|
||||
})
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: `${item.value?.name} | ThrillWiki`,
|
||||
description: item.value?.description,
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. For Form Pages
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
// Validate
|
||||
const result = schema.safeParse(form)
|
||||
if (!result.success) {
|
||||
errors.value = result.error.flatten().fieldErrors
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await $fetch('/api/v1/items/', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
await navigateTo('/items')
|
||||
} catch (e) {
|
||||
// Handle API errors
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
After creating the page, verify:
|
||||
|
||||
- [ ] Page renders without errors
|
||||
- [ ] SEO meta tags are set
|
||||
- [ ] Loading states display correctly
|
||||
- [ ] Error states are handled
|
||||
- [ ] Page is responsive (mobile, tablet, desktop)
|
||||
- [ ] Keyboard navigation works
|
||||
- [ ] Data fetches efficiently (no N+1 issues)
|
||||
- [ ] URL parameters persist correctly (for list pages)
|
||||
- [ ] Authentication is enforced (if required)
|
||||
|
||||
## Output
|
||||
|
||||
Report what was created:
|
||||
```
|
||||
Created: frontend/pages/[path].vue
|
||||
Route: /[route]
|
||||
Type: [list/detail/form/static]
|
||||
Features: [list of features implemented]
|
||||
```
|
||||
Reference in New Issue
Block a user