mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 06:05:18 -05:00
6.6 KiB
6.6 KiB
description
| 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
- Component Name: PascalCase (e.g.,
ParkCard,RatingDisplay) - Category:
ui/- Base components (Button, Card, Input)entity/- Domain-specific (ParkCard, RideCard)forms/- Form componentsspecialty/- Complex/unique components
- Props: What data does it receive?
- Emits: What events does it emit?
- State: Does it have internal state?
- Variants: Does it need multiple variants/sizes?
Component Template
Base Component Structure
Location: frontend/components/[category]/ComponentName.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.:
<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:
<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:
<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:
// 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]