mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 07:45:18 -05:00
280 lines
6.6 KiB
Markdown
280 lines
6.6 KiB
Markdown
---
|
|
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]
|
|
```
|