feat: Add PrimeProgress, PrimeSelect, and PrimeSkeleton components with customizable styles and props

- Implemented PrimeProgress component with support for labels, helper text, and various styles (size, variant, color).
- Created PrimeSelect component with dropdown functionality, custom templates, and validation states.
- Developed PrimeSkeleton component for loading placeholders with different shapes and animations.
- Updated index.ts to export new components for easy import.
- Enhanced PrimeVueTest.vue to include tests for new components and their functionalities.
- Introduced a custom ThrillWiki theme for PrimeVue with tailored color schemes and component styles.
- Added ambient type declarations for various components to improve TypeScript support.
This commit is contained in:
pacnpal
2025-08-27 21:00:02 -04:00
parent 6125c4ee44
commit 08a4a2d034
164 changed files with 73094 additions and 11001 deletions

View File

@@ -1 +1,3 @@
* text=auto eol=lf
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

3
frontend/.gitignore vendored
View File

@@ -31,3 +31,6 @@ coverage
test-results/
playwright-report/
# pixi environments
.pixi/*
!.pixi/config.toml

1272
frontend/bun.lock Normal file

File diff suppressed because it is too large Load Diff

216
frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,216 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActiveFilterChip: typeof import('./src/components/filters/ActiveFilterChip.vue')['default']
AlertDialog: typeof import('./src/components/ui/alert-dialog/AlertDialog.vue')['default']
AlertDialogAction: typeof import('./src/components/ui/alert-dialog/AlertDialogAction.vue')['default']
AlertDialogCancel: typeof import('./src/components/ui/alert-dialog/AlertDialogCancel.vue')['default']
AlertDialogContent: typeof import('./src/components/ui/alert-dialog/AlertDialogContent.vue')['default']
AlertDialogDescription: typeof import('./src/components/ui/alert-dialog/AlertDialogDescription.vue')['default']
AlertDialogFooter: typeof import('./src/components/ui/alert-dialog/AlertDialogFooter.vue')['default']
AlertDialogHeader: typeof import('./src/components/ui/alert-dialog/AlertDialogHeader.vue')['default']
AlertDialogTitle: typeof import('./src/components/ui/alert-dialog/AlertDialogTitle.vue')['default']
AlertDialogTrigger: typeof import('./src/components/ui/alert-dialog/AlertDialogTrigger.vue')['default']
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
AuthManager: typeof import('./src/components/auth/AuthManager.vue')['default']
AuthModal: typeof import('./src/components/auth/AuthModal.vue')['default']
AuthPrompt: typeof import('./src/components/entity/AuthPrompt.vue')['default']
Avatar: typeof import('./src/components/ui/avatar/Avatar.vue')['default']
AvatarFallback: typeof import('./src/components/ui/avatar/AvatarFallback.vue')['default']
AvatarImage: typeof import('./src/components/ui/avatar/AvatarImage.vue')['default']
Badge: typeof import('./src/components/ui/Badge.vue')['default']
Breadcrumb: typeof import('./src/components/ui/breadcrumb/Breadcrumb.vue')['default']
BreadcrumbItem: typeof import('./src/components/ui/breadcrumb/BreadcrumbItem.vue')['default']
BreadcrumbLink: typeof import('./src/components/ui/breadcrumb/BreadcrumbLink.vue')['default']
BreadcrumbList: typeof import('./src/components/ui/breadcrumb/BreadcrumbList.vue')['default']
BreadcrumbPage: typeof import('./src/components/ui/breadcrumb/BreadcrumbPage.vue')['default']
BreadcrumbSeparator: typeof import('./src/components/ui/breadcrumb/BreadcrumbSeparator.vue')['default']
Button: typeof import('./src/components/ui/Button.vue')['default']
Card: typeof import('./src/components/ui/Card.vue')['default']
CardAction: typeof import('./src/components/ui/card/CardAction.vue')['default']
CardContent: typeof import('./src/components/ui/card/CardContent.vue')['default']
CardDescription: typeof import('./src/components/ui/card/CardDescription.vue')['default']
CardFooter: typeof import('./src/components/ui/card/CardFooter.vue')['default']
CardHeader: typeof import('./src/components/ui/card/CardHeader.vue')['default']
CardTitle: typeof import('./src/components/ui/card/CardTitle.vue')['default']
Collapsible: typeof import('./src/components/ui/collapsible/Collapsible.vue')['default']
CollapsibleContent: typeof import('./src/components/ui/collapsible/CollapsibleContent.vue')['default']
CollapsibleTrigger: typeof import('./src/components/ui/collapsible/CollapsibleTrigger.vue')['default']
Command: typeof import('./src/components/ui/command/Command.vue')['default']
CommandDialog: typeof import('./src/components/ui/command/CommandDialog.vue')['default']
CommandEmpty: typeof import('./src/components/ui/command/CommandEmpty.vue')['default']
CommandGroup: typeof import('./src/components/ui/command/CommandGroup.vue')['default']
CommandInput: typeof import('./src/components/ui/command/CommandInput.vue')['default']
CommandItem: typeof import('./src/components/ui/command/CommandItem.vue')['default']
CommandList: typeof import('./src/components/ui/command/CommandList.vue')['default']
CommandSeparator: typeof import('./src/components/ui/command/CommandSeparator.vue')['default']
CommandShortcut: typeof import('./src/components/ui/command/CommandShortcut.vue')['default']
ContextMenu: typeof import('./src/components/ui/context-menu/ContextMenu.vue')['default']
ContextMenuCheckboxItem: typeof import('./src/components/ui/context-menu/ContextMenuCheckboxItem.vue')['default']
ContextMenuContent: typeof import('./src/components/ui/context-menu/ContextMenuContent.vue')['default']
ContextMenuGroup: typeof import('./src/components/ui/context-menu/ContextMenuGroup.vue')['default']
ContextMenuItem: typeof import('./src/components/ui/context-menu/ContextMenuItem.vue')['default']
ContextMenuLabel: typeof import('./src/components/ui/context-menu/ContextMenuLabel.vue')['default']
ContextMenuPortal: typeof import('./src/components/ui/context-menu/ContextMenuPortal.vue')['default']
ContextMenuRadioGroup: typeof import('./src/components/ui/context-menu/ContextMenuRadioGroup.vue')['default']
ContextMenuRadioItem: typeof import('./src/components/ui/context-menu/ContextMenuRadioItem.vue')['default']
ContextMenuSeparator: typeof import('./src/components/ui/context-menu/ContextMenuSeparator.vue')['default']
ContextMenuShortcut: typeof import('./src/components/ui/context-menu/ContextMenuShortcut.vue')['default']
ContextMenuSub: typeof import('./src/components/ui/context-menu/ContextMenuSub.vue')['default']
ContextMenuSubContent: typeof import('./src/components/ui/context-menu/ContextMenuSubContent.vue')['default']
ContextMenuSubTrigger: typeof import('./src/components/ui/context-menu/ContextMenuSubTrigger.vue')['default']
ContextMenuTrigger: typeof import('./src/components/ui/context-menu/ContextMenuTrigger.vue')['default']
DateRangeFilter: typeof import('./src/components/filters/DateRangeFilter.vue')['default']
Dialog: typeof import('./src/components/ui/dialog/Dialog.vue')['default']
DialogClose: typeof import('./src/components/ui/dialog/DialogClose.vue')['default']
DialogContent: typeof import('./src/components/ui/dialog/DialogContent.vue')['default']
DialogDescription: typeof import('./src/components/ui/dialog/DialogDescription.vue')['default']
DialogFooter: typeof import('./src/components/ui/dialog/DialogFooter.vue')['default']
DialogHeader: typeof import('./src/components/ui/dialog/DialogHeader.vue')['default']
DialogOverlay: typeof import('./src/components/ui/dialog/DialogOverlay.vue')['default']
DialogScrollContent: typeof import('./src/components/ui/dialog/DialogScrollContent.vue')['default']
DialogTitle: typeof import('./src/components/ui/dialog/DialogTitle.vue')['default']
DialogTrigger: typeof import('./src/components/ui/dialog/DialogTrigger.vue')['default']
DiscordIcon: typeof import('./src/components/icons/DiscordIcon.vue')['default']
Divider: typeof import('primevue/divider')['default']
Dropdown: typeof import('primevue/dropdown')['default']
DropdownMenu: typeof import('./src/components/ui/dropdown-menu/DropdownMenu.vue')['default']
DropdownMenuCheckboxItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue')['default']
DropdownMenuContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuContent.vue')['default']
DropdownMenuGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuGroup.vue')['default']
DropdownMenuItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuItem.vue')['default']
DropdownMenuLabel: typeof import('./src/components/ui/dropdown-menu/DropdownMenuLabel.vue')['default']
DropdownMenuRadioGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue')['default']
DropdownMenuRadioItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue')['default']
DropdownMenuSeparator: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSeparator.vue')['default']
DropdownMenuShortcut: typeof import('./src/components/ui/dropdown-menu/DropdownMenuShortcut.vue')['default']
DropdownMenuSub: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSub.vue')['default']
DropdownMenuSubContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubContent.vue')['default']
DropdownMenuSubTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue')['default']
DropdownMenuTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuTrigger.vue')['default']
EntitySuggestionCard: typeof import('./src/components/entity/EntitySuggestionCard.vue')['default']
EntitySuggestionManager: typeof import('./src/components/entity/EntitySuggestionManager.vue')['default']
EntitySuggestionModal: typeof import('./src/components/entity/EntitySuggestionModal.vue')['default']
FilterSection: typeof import('./src/components/filters/FilterSection.vue')['default']
ForgotPasswordModal: typeof import('./src/components/auth/ForgotPasswordModal.vue')['default']
GoogleIcon: typeof import('./src/components/icons/GoogleIcon.vue')['default']
HoverCard: typeof import('./src/components/ui/hover-card/HoverCard.vue')['default']
HoverCardContent: typeof import('./src/components/ui/hover-card/HoverCardContent.vue')['default']
HoverCardTrigger: typeof import('./src/components/ui/hover-card/HoverCardTrigger.vue')['default']
Icon: typeof import('./src/components/ui/Icon.vue')['default']
Input: typeof import('./src/components/ui/Input.vue')['default']
InputText: typeof import('primevue/inputtext')['default']
LoginModal: typeof import('./src/components/auth/LoginModal.vue')['default']
Menu: typeof import('primevue/menu')['default']
Menubar: typeof import('./src/components/ui/menubar/Menubar.vue')['default']
MenubarCheckboxItem: typeof import('./src/components/ui/menubar/MenubarCheckboxItem.vue')['default']
MenubarContent: typeof import('./src/components/ui/menubar/MenubarContent.vue')['default']
MenubarGroup: typeof import('./src/components/ui/menubar/MenubarGroup.vue')['default']
MenubarItem: typeof import('./src/components/ui/menubar/MenubarItem.vue')['default']
MenubarLabel: typeof import('./src/components/ui/menubar/MenubarLabel.vue')['default']
MenubarMenu: typeof import('./src/components/ui/menubar/MenubarMenu.vue')['default']
MenubarRadioGroup: typeof import('./src/components/ui/menubar/MenubarRadioGroup.vue')['default']
MenubarRadioItem: typeof import('./src/components/ui/menubar/MenubarRadioItem.vue')['default']
MenubarSeparator: typeof import('./src/components/ui/menubar/MenubarSeparator.vue')['default']
MenubarShortcut: typeof import('./src/components/ui/menubar/MenubarShortcut.vue')['default']
MenubarSub: typeof import('./src/components/ui/menubar/MenubarSub.vue')['default']
MenubarSubContent: typeof import('./src/components/ui/menubar/MenubarSubContent.vue')['default']
MenubarSubTrigger: typeof import('./src/components/ui/menubar/MenubarSubTrigger.vue')['default']
MenubarTrigger: typeof import('./src/components/ui/menubar/MenubarTrigger.vue')['default']
Navbar: typeof import('./src/components/layout/Navbar.vue')['default']
Popover: typeof import('./src/components/ui/popover/Popover.vue')['default']
PopoverAnchor: typeof import('./src/components/ui/popover/PopoverAnchor.vue')['default']
PopoverContent: typeof import('./src/components/ui/popover/PopoverContent.vue')['default']
PopoverTrigger: typeof import('./src/components/ui/popover/PopoverTrigger.vue')['default']
PresetItem: typeof import('./src/components/filters/PresetItem.vue')['default']
PrimeBadge: typeof import('./src/components/primevue/PrimeBadge.vue')['default']
PrimeButton: typeof import('./src/components/primevue/PrimeButton.vue')['default']
PrimeCard: typeof import('./src/components/primevue/PrimeCard.vue')['default']
PrimeDialog: typeof import('./src/components/primevue/PrimeDialog.vue')['default']
PrimeInput: typeof import('./src/components/primevue/PrimeInput.vue')['default']
PrimeProgress: typeof import('./src/components/primevue/PrimeProgress.vue')['default']
PrimeSelect: typeof import('./src/components/primevue/PrimeSelect.vue')['default']
PrimeSkeleton: typeof import('./src/components/primevue/PrimeSkeleton.vue')['default']
PrimeThemeController: typeof import('./src/components/layout/PrimeThemeController.vue')['default']
PrimeVueTest: typeof import('./src/components/test/PrimeVueTest.vue')['default']
Progress: typeof import('./src/components/ui/progress/Progress.vue')['default']
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RangeFilter: typeof import('./src/components/filters/RangeFilter.vue')['default']
RideCard: typeof import('./src/components/rides/RideCard.vue')['default']
RideFilterSidebar: typeof import('./src/components/filters/RideFilterSidebar.vue')['default']
RideListDisplay: typeof import('./src/components/rides/RideListDisplay.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SavePresetDialog: typeof import('./src/components/filters/SavePresetDialog.vue')['default']
ScrollArea: typeof import('./src/components/ui/scroll-area/ScrollArea.vue')['default']
ScrollBar: typeof import('./src/components/ui/scroll-area/ScrollBar.vue')['default']
SearchableSelect: typeof import('./src/components/filters/SearchableSelect.vue')['default']
SearchFilter: typeof import('./src/components/filters/SearchFilter.vue')['default']
SearchInput: typeof import('./src/components/ui/SearchInput.vue')['default']
Select: typeof import('./src/components/ui/select/Select.vue')['default']
SelectContent: typeof import('./src/components/ui/select/SelectContent.vue')['default']
SelectFilter: typeof import('./src/components/filters/SelectFilter.vue')['default']
SelectGroup: typeof import('./src/components/ui/select/SelectGroup.vue')['default']
SelectItem: typeof import('./src/components/ui/select/SelectItem.vue')['default']
SelectItemText: typeof import('./src/components/ui/select/SelectItemText.vue')['default']
SelectLabel: typeof import('./src/components/ui/select/SelectLabel.vue')['default']
SelectScrollDownButton: typeof import('./src/components/ui/select/SelectScrollDownButton.vue')['default']
SelectScrollUpButton: typeof import('./src/components/ui/select/SelectScrollUpButton.vue')['default']
SelectSeparator: typeof import('./src/components/ui/select/SelectSeparator.vue')['default']
SelectTrigger: typeof import('./src/components/ui/select/SelectTrigger.vue')['default']
SelectValue: typeof import('./src/components/ui/select/SelectValue.vue')['default']
Separator: typeof import('./src/components/ui/separator/Separator.vue')['default']
Sheet: typeof import('./src/components/ui/sheet/Sheet.vue')['default']
SheetClose: typeof import('./src/components/ui/sheet/SheetClose.vue')['default']
SheetContent: typeof import('./src/components/ui/sheet/SheetContent.vue')['default']
SheetDescription: typeof import('./src/components/ui/sheet/SheetDescription.vue')['default']
SheetFooter: typeof import('./src/components/ui/sheet/SheetFooter.vue')['default']
SheetHeader: typeof import('./src/components/ui/sheet/SheetHeader.vue')['default']
SheetOverlay: typeof import('./src/components/ui/sheet/SheetOverlay.vue')['default']
SheetTitle: typeof import('./src/components/ui/sheet/SheetTitle.vue')['default']
SheetTrigger: typeof import('./src/components/ui/sheet/SheetTrigger.vue')['default']
Sidebar: typeof import('./src/components/ui/sidebar/Sidebar.vue')['default']
SidebarContent: typeof import('./src/components/ui/sidebar/SidebarContent.vue')['default']
SidebarFooter: typeof import('./src/components/ui/sidebar/SidebarFooter.vue')['default']
SidebarGroup: typeof import('./src/components/ui/sidebar/SidebarGroup.vue')['default']
SidebarGroupAction: typeof import('./src/components/ui/sidebar/SidebarGroupAction.vue')['default']
SidebarGroupContent: typeof import('./src/components/ui/sidebar/SidebarGroupContent.vue')['default']
SidebarGroupLabel: typeof import('./src/components/ui/sidebar/SidebarGroupLabel.vue')['default']
SidebarHeader: typeof import('./src/components/ui/sidebar/SidebarHeader.vue')['default']
SidebarInput: typeof import('./src/components/ui/sidebar/SidebarInput.vue')['default']
SidebarInset: typeof import('./src/components/ui/sidebar/SidebarInset.vue')['default']
SidebarMenu: typeof import('./src/components/ui/sidebar/SidebarMenu.vue')['default']
SidebarMenuAction: typeof import('./src/components/ui/sidebar/SidebarMenuAction.vue')['default']
SidebarMenuBadge: typeof import('./src/components/ui/sidebar/SidebarMenuBadge.vue')['default']
SidebarMenuButton: typeof import('./src/components/ui/sidebar/SidebarMenuButton.vue')['default']
SidebarMenuButtonChild: typeof import('./src/components/ui/sidebar/SidebarMenuButtonChild.vue')['default']
SidebarMenuItem: typeof import('./src/components/ui/sidebar/SidebarMenuItem.vue')['default']
SidebarMenuSkeleton: typeof import('./src/components/ui/sidebar/SidebarMenuSkeleton.vue')['default']
SidebarMenuSub: typeof import('./src/components/ui/sidebar/SidebarMenuSub.vue')['default']
SidebarMenuSubButton: typeof import('./src/components/ui/sidebar/SidebarMenuSubButton.vue')['default']
SidebarMenuSubItem: typeof import('./src/components/ui/sidebar/SidebarMenuSubItem.vue')['default']
SidebarProvider: typeof import('./src/components/ui/sidebar/SidebarProvider.vue')['default']
SidebarRail: typeof import('./src/components/ui/sidebar/SidebarRail.vue')['default']
SidebarSeparator: typeof import('./src/components/ui/sidebar/SidebarSeparator.vue')['default']
SidebarTrigger: typeof import('./src/components/ui/sidebar/SidebarTrigger.vue')['default']
SignupModal: typeof import('./src/components/auth/SignupModal.vue')['default']
Skeleton: typeof import('./src/components/ui/skeleton/Skeleton.vue')['default']
Slider: typeof import('./src/components/ui/slider/Slider.vue')['default']
Tabs: typeof import('./src/components/ui/tabs/Tabs.vue')['default']
TabsContent: typeof import('./src/components/ui/tabs/TabsContent.vue')['default']
TabsList: typeof import('./src/components/ui/tabs/TabsList.vue')['default']
TabsTrigger: typeof import('./src/components/ui/tabs/TabsTrigger.vue')['default']
ThemeController: typeof import('./src/components/layout/ThemeController.vue')['default']
Tooltip: typeof import('./src/components/ui/tooltip/Tooltip.vue')['default']
TooltipContent: typeof import('./src/components/ui/tooltip/TooltipContent.vue')['default']
TooltipProvider: typeof import('./src/components/ui/tooltip/TooltipProvider.vue')['default']
TooltipTrigger: typeof import('./src/components/ui/tooltip/TooltipTrigger.vue')['default']
}
}

View File

@@ -1,13 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -22,18 +22,24 @@
},
"dependencies": {
"@csstools/normalize.css": "^12.1.1",
"@heroicons/vue": "^2.2.0",
"@material/material-color-utilities": "^0.3.0",
"@primeuix/themes": "^1.2.3",
"@primevue/forms": "^4.3.7",
"@primevue/themes": "^4.3.7",
"@vueuse/core": "^13.8.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.541.0",
"pinia": "^3.0.3",
"vue": "^3.5.19",
"primeicons": "^7.0.0",
"primevue": "^4.3.7",
"tw-animate-css": "^1.3.7",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@chainlift/liftkit": "^0.2.0",
"@playwright/test": "^1.55.0",
"@prettier/plugin-oxc": "^0.0.4",
"@primevue/auto-import-resolver": "^4.3.7",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tsconfig/node22": "^22.0.2",
@@ -47,20 +53,24 @@
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.34.0",
"eslint-plugin-oxlint": "~1.12.0",
"eslint-plugin-oxlint": "~1.13.0",
"eslint-plugin-playwright": "^2.2.2",
"eslint-plugin-vue": "~10.4.0",
"jiti": "^2.5.1",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.12.0",
"oxlint": "~1.13.0",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^4.1.12",
"typescript": "~5.9.2",
"vite": "^6.0.1",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.1.3",
"vite-plugin-vue-devtools": "^8.0.1",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
}
},
"trustedDependencies": [
"@tailwindcss/oxide"
]
}

856
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- vue-demi

View File

@@ -1,5 +1,5 @@
<template>
<div id="app" class="min-h-screen bg-gray-50 dark:bg-gray-900">
<div class="min-h-screen bg-background text-foreground">
<!-- Authentication Modals -->
<AuthManager
:show="showAuthModal"
@@ -7,223 +7,77 @@
@close="closeAuthModal"
@success="handleAuthSuccess"
/>
<!-- Header Navigation -->
<header
class="sticky top-0 z-50 bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"
>
<nav class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
<div class="flex-shrink-0">
<router-link to="/" class="flex items-center space-x-2">
<div
class="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center"
>
<span class="text-white font-bold text-sm">TW</span>
</div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">ThrillWiki</h1>
</router-link>
</div>
<!-- Browse Dropdown & Search -->
<div class="flex items-center space-x-4 flex-1 max-w-2xl mx-6">
<!-- Browse Dropdown -->
<div class="relative" v-if="!isMobile">
<button
@click="browseDropdownOpen = !browseDropdownOpen"
class="flex items-center space-x-1 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<span>Browse</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Dropdown Menu -->
<div
v-show="browseDropdownOpen"
class="absolute left-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50"
>
<div class="py-1">
<router-link
to="/parks/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
All Parks
</router-link>
<router-link
to="/rides/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
All Rides
</router-link>
<router-link
to="/search/parks/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
Search Parks
</router-link>
<router-link
to="/search/rides/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
Search Rides
</router-link>
</div>
</div>
</div>
<!-- Search Bar -->
<div class="flex-1 max-w-lg">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
class="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="search"
placeholder="Search parks, rides..."
class="block w-full pl-10 pr-16 py-2 border border-gray-300 rounded-md leading-5 bg-white dark:bg-gray-700 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
v-model="searchQuery"
@keyup.enter="handleSearch"
/>
<button
@click="handleSearch"
class="absolute inset-y-0 right-0 px-4 bg-gray-900 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-500 rounded-r-md text-white text-sm font-medium transition-colors"
>
Search
</button>
</div>
</div>
</div>
<!-- Right Side Actions -->
<div class="flex items-center space-x-3">
<!-- Theme Toggle -->
<button
@click="toggleTheme"
class="p-2 rounded-md text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
>
<svg
v-if="isDark"
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
</button>
<!-- Sign In / Sign Up -->
<div class="hidden md:flex items-center space-x-2">
<button
@click="showLoginModal"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Sign In
</button>
<button
@click="showSignupModal"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
>
Sign Up
</button>
</div>
<!-- Mobile menu button -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden p-2 rounded-md text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-if="!mobileMenuOpen"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- Header -->
<header class="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container flex h-16 items-center">
<!-- Logo -->
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-gradient-to-br from-primary to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-primary-foreground font-bold text-sm">TW</span>
</div>
<router-link to="/" class="text-lg font-bold text-foreground hover:text-primary transition-colors">
ThrillWiki
</router-link>
</div>
<!-- Mobile Navigation Menu -->
<div v-show="mobileMenuOpen" class="md:hidden">
<div
class="px-2 pt-2 pb-3 space-y-1 sm:px-3 border-t border-gray-200 dark:border-gray-700"
<!-- Navigation -->
<nav class="flex items-center space-x-6 ml-8">
<router-link
to="/parks/"
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<router-link
to="/parks/"
class="text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 block px-3 py-2 rounded-md text-base font-medium transition-colors"
@click="mobileMenuOpen = false"
Parks
</router-link>
<router-link
to="/rides/"
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Rides
</router-link>
</nav>
<!-- Header Actions -->
<div class="ml-auto flex items-center gap-2">
<!-- Search -->
<div class="relative hidden md:block">
<i class="pi pi-search absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"></i>
<InputText
type="search"
placeholder="Search parks, rides..."
class="w-[300px] pl-8"
v-model="searchQuery"
@keyup.enter="handleSearch"
/>
</div>
<!-- Theme Toggle -->
<Button variant="secondary" @click="toggleTheme" class="p-2">
<i v-if="appliedTheme === 'dark'" class="pi pi-sun h-4 w-4"></i>
<i v-else class="pi pi-moon h-4 w-4"></i>
<span class="sr-only">Toggle theme</span>
</Button>
<!-- User Menu -->
<div class="relative">
<Button
variant="secondary"
@click="toggleUserMenu"
ref="userMenuButton"
class="p-2"
>
Parks
</router-link>
<router-link
to="/rides/"
class="text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 block px-3 py-2 rounded-md text-base font-medium transition-colors"
@click="mobileMenuOpen = false"
>
Rides
</router-link>
<div class="border-t border-gray-200 dark:border-gray-700 mt-3 pt-3">
<button
@click="showLoginModal"
class="w-full text-left px-3 py-2 text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors"
>
Sign In
</button>
<button
@click="showSignupModal"
class="w-full text-left px-3 py-2 text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors"
>
Sign Up
</button>
</div>
<i class="pi pi-user h-4 w-4"></i>
<span class="sr-only">User menu</span>
</Button>
<Menu
ref="userMenu"
:model="userMenuItems"
:popup="true"
class="mt-2"
/>
</div>
</div>
</nav>
</div>
</header>
<!-- Main Content -->
@@ -232,127 +86,106 @@
</main>
<!-- Footer -->
<footer class="bg-white border-t border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<footer class="bg-card border-t border-border">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Logo & Description -->
<!-- Brand -->
<div class="col-span-1">
<div class="flex items-center space-x-2 mb-4">
<div
class="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center"
>
<span class="text-white font-bold text-sm">TW</span>
<div class="w-8 h-8 bg-gradient-to-br from-primary to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-primary-foreground font-bold text-sm">TW</span>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">ThrillWiki</h3>
<h3 class="text-lg font-bold text-foreground">
ThrillWiki
</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 text-sm max-w-xs">
The ultimate database for theme park rides and attractions worldwide.
<p class="text-muted-foreground text-sm max-w-xs">
Your ultimate guide to theme parks and thrilling rides around the world.
</p>
</div>
<!-- Explore -->
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Explore</h4>
<h4 class="font-semibold text-foreground mb-4">Explore</h4>
<ul class="space-y-2">
<li>
<router-link
to="/parks/"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Parks</router-link
class="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Parks
</router-link>
</li>
<li>
<router-link
to="/rides/"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Rides</router-link
class="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Rides
</router-link>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Manufacturers</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Manufacturers
</a>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Operators</a
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Top Lists</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Operators
</a>
</li>
</ul>
</div>
<!-- Community -->
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Community</h4>
<h4 class="font-semibold text-foreground mb-4">Community</h4>
<ul class="space-y-2">
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Join ThrillWiki</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Join ThrillWiki
</a>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Contribute</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Contribute
</a>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Guidelines</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Community Guidelines
</a>
</li>
</ul>
</div>
<!-- Legal -->
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Legal</h4>
<h4 class="font-semibold text-foreground mb-4">Legal</h4>
<ul class="space-y-2">
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Terms of Service</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Privacy Policy
</a>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Privacy Policy</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Terms of Service
</a>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Contact</a
>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Contact
</a>
</li>
</ul>
</div>
</div>
<!-- Copyright -->
<div class="border-t border-gray-200 dark:border-gray-700 mt-8 pt-8">
<p class="text-center text-gray-600 dark:text-gray-400 text-sm">
© 2024 ThrillWiki. All rights reserved.
<div class="border-t border-border mt-8 pt-8">
<p class="text-center text-muted-foreground text-sm">
© 2025 ThrillWiki. All rights reserved.
</p>
</div>
</div>
@@ -361,58 +194,55 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import AuthManager from '@/components/auth/AuthManager.vue'
import { useTheme } from './composables/useTheme'
import AuthManager from './components/auth/AuthManager.vue'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Menu from 'primevue/menu'
const router = useRouter()
const searchQuery = ref('')
const mobileMenuOpen = ref(false)
const browseDropdownOpen = ref(false)
const isDark = ref(false)
const isMobile = ref(false)
// Theme management using the useTheme composable
const { appliedTheme, toggleTheme, initializeTheme } = useTheme()
// Authentication modal state
const showAuthModal = ref(false)
const authModalMode = ref<'login' | 'signup'>('login')
// Mobile detection with proper lifecycle management
const updateMobileDetection = () => {
isMobile.value = window.innerWidth < 768
}
// User menu
const userMenu = ref()
const userMenuButton = ref()
// Theme Management
const toggleTheme = () => {
isDark.value = !isDark.value
if (isDark.value) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
// User menu items
const userMenuItems = computed(() => [
{
label: 'Sign In',
icon: 'pi pi-sign-in',
command: () => showLoginModal()
},
{
label: 'Sign Up',
icon: 'pi pi-user-plus',
command: () => showSignupModal()
}
}
])
// Initialize theme and mobile detection
// Initialize theme on mount
onMounted(() => {
// Initialize mobile detection
updateMobileDetection()
window.addEventListener('resize', updateMobileDetection)
// Initialize theme from localStorage
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
isDark.value = savedTheme === 'dark' || (!savedTheme && prefersDark)
if (isDark.value) {
document.documentElement.classList.add('dark')
}
initializeTheme()
// Listen for sidebar filter changes
window.addEventListener('sidebar-filter-change', handleSidebarFilterChange)
window.addEventListener('show-login', handleShowLogin)
})
// Cleanup event listeners
onUnmounted(() => {
window.removeEventListener('resize', updateMobileDetection)
window.removeEventListener('sidebar-filter-change', handleSidebarFilterChange)
window.removeEventListener('show-login', handleShowLogin)
})
// Search functionality
@@ -420,40 +250,68 @@ const handleSearch = () => {
if (searchQuery.value.trim()) {
router.push({
name: 'search-results',
query: { q: searchQuery.value.trim() },
query: { q: searchQuery.value.trim() }
})
}
}
// User menu functionality
const toggleUserMenu = (event: Event) => {
userMenu.value.toggle(event)
}
// Sidebar event handlers
const handleSidebarFilterChange = (event: CustomEvent) => {
// Handle filter changes from sidebar
console.log('Sidebar filters changed:', event.detail)
}
const handleShowLogin = () => {
showLoginModal()
}
// Authentication modal functions
const showLoginModal = () => {
authModalMode.value = 'login'
showAuthModal.value = true
mobileMenuOpen.value = false // Close mobile menu if open
}
const showSignupModal = () => {
authModalMode.value = 'signup'
showAuthModal.value = true
mobileMenuOpen.value = false // Close mobile menu if open
}
const closeAuthModal = () => {
showAuthModal.value = false
}
const handleAuthSuccess = () => {
const handleAuthSuccess = (data: { mode: 'login' | 'signup', email: string }) => {
// Handle successful authentication
console.log('Authentication successful!', data)
// This could include redirecting to a dashboard, updating user state, etc.
console.log('Authentication successful!')
}
</script>
<style scoped>
@reference "./style.css";
/* Additional component-specific styles if needed */
.router-link-active {
@apply text-blue-600 dark:text-blue-400;
@apply text-primary;
}
/* Ensure proper theme integration */
:deep(.p-inputtext) {
@apply bg-background border-border text-foreground;
}
:deep(.p-button) {
@apply transition-colors;
}
:deep(.p-menu) {
@apply bg-background border-border shadow-lg;
}
:deep(.p-menu .p-menuitem-link) {
@apply text-foreground hover:bg-muted;
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<div class="h-full w-64 bg-surface-0 dark:bg-surface-900 border-r border-surface-200 dark:border-surface-700 flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-surface-200 dark:border-surface-700">
<router-link to="/" class="flex items-center gap-3 text-decoration-none">
<div
class="flex aspect-square w-8 h-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-purple-600 text-white"
>
<span class="font-bold text-sm">TW</span>
</div>
<div class="flex-1">
<div class="font-semibold text-surface-900 dark:text-surface-0">ThrillWiki</div>
<div class="text-xs text-surface-600 dark:text-surface-400">Theme Park Database</div>
</div>
</router-link>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Main Navigation -->
<div>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-3">Browse</div>
<div class="space-y-1">
<router-link
to="/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors"
:class="$route.path === '/' ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-home text-base"></i>
<span>Home</span>
</router-link>
<div class="space-y-1">
<div class="flex items-center">
<router-link
to="/parks/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors flex-1"
:class="$route.path.startsWith('/parks') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-building text-base"></i>
<span>Parks</span>
</router-link>
<Button
text
size="small"
class="ml-1"
@click="toggleParksSubmenu"
>
<i class="pi pi-plus text-xs"></i>
</Button>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center">
<router-link
to="/rides/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors flex-1"
:class="$route.path.startsWith('/rides') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-bolt text-base"></i>
<span>Rides</span>
</router-link>
<Button
text
size="small"
class="ml-1"
@click="toggleRidesSubmenu"
>
<i class="pi pi-plus text-xs"></i>
</Button>
</div>
</div>
</div>
</div>
<!-- Quick Filters -->
<div>
<div
class="flex items-center justify-between cursor-pointer mb-3"
@click="filtersOpen = !filtersOpen"
>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400">Quick Filters</div>
<i
:class="['pi text-xs transition-transform', filtersOpen ? 'pi-chevron-down' : 'pi-chevron-right']"
></i>
</div>
<div v-show="filtersOpen" class="space-y-1">
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('featured') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('featured')"
>
<div class="flex items-center gap-3">
<i class="pi pi-star text-base"></i>
<span>Featured</span>
</div>
<Badge v-if="featuredCount" :value="featuredCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('roller_coaster') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('roller_coaster')"
>
<div class="flex items-center gap-3">
<i class="pi pi-angle-double-up text-base"></i>
<span>Roller Coasters</span>
</div>
<Badge v-if="coasterCount" :value="coasterCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('water_ride') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('water_ride')"
>
<div class="flex items-center gap-3">
<i class="pi pi-cloud-download text-base"></i>
<span>Water Rides</span>
</div>
<Badge v-if="waterRideCount" :value="waterRideCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('family') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('family')"
>
<div class="flex items-center gap-3">
<i class="pi pi-users text-base"></i>
<span>Family Rides</span>
</div>
<Badge v-if="familyRideCount" :value="familyRideCount" size="small" />
</div>
</div>
</div>
<!-- Recent Activity -->
<div>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-3">Recent</div>
<div class="space-y-1">
<router-link
v-for="item in recentItems"
:key="item.id"
:to="item.path"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800"
>
<i :class="item.icon" class="text-base"></i>
<span>{{ item.name }}</span>
</router-link>
<div v-if="recentItems.length === 0" class="space-y-2">
<Skeleton height="2rem" />
<Skeleton height="2rem" />
<Skeleton height="2rem" />
</div>
</div>
</div>
<!-- Countries -->
<div>
<div
class="flex items-center justify-between cursor-pointer mb-3"
@click="countriesOpen = !countriesOpen"
>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400">Countries</div>
<i
:class="['pi text-xs transition-transform', countriesOpen ? 'pi-chevron-down' : 'pi-chevron-right']"
></i>
</div>
<div v-show="countriesOpen" class="space-y-1">
<div
v-for="country in countries"
:key="country.code"
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="selectedCountry === country.code ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="filterByCountry(country.code)"
>
<div class="flex items-center gap-3">
<span class="text-base">{{ country.flag }}</span>
<span>{{ country.name }}</span>
</div>
<Badge v-if="country.count" :value="country.count" size="small" />
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-4 border-t border-surface-200 dark:border-surface-700">
<div class="relative">
<div
class="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors hover:bg-surface-100 dark:hover:bg-surface-800"
@click="userMenuVisible = true"
>
<Avatar
:label="user?.name?.charAt(0) || 'G'"
class="w-8 h-8"
shape="circle"
/>
<div class="flex-1 text-left">
<div class="font-semibold text-surface-900 dark:text-surface-0 text-sm">{{ user?.name || 'Guest' }}</div>
<div class="text-xs text-surface-600 dark:text-surface-400">{{ user?.email || 'Not signed in' }}</div>
</div>
<i class="pi pi-chevron-up text-xs"></i>
</div>
<Menu
ref="userMenu"
v-model:visible="userMenuVisible"
:model="userMenuItems"
popup
class="w-56"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// PrimeVue Components
import Button from 'primevue/button'
import Badge from 'primevue/badge'
import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
const route = useRoute()
const router = useRouter()
// State
const filtersOpen = ref(true)
const countriesOpen = ref(false)
const userMenuVisible = ref(false)
const activeFilters = ref<string[]>([])
const selectedCountry = ref<string>('')
// Menu reference
const userMenu = ref()
// Mock user data - replace with actual auth state
const user = ref<{ name: string; email: string; avatar?: string } | null>(null)
// Mock data - replace with actual API calls
const featuredCount = ref(12)
const coasterCount = ref(156)
const waterRideCount = ref(43)
const familyRideCount = ref(89)
const recentItems = ref([
{ id: 1, name: 'Cedar Point', path: '/parks/cedar-point/', icon: 'pi pi-building' },
{ id: 2, name: 'Steel Vengeance', path: '/parks/cedar-point/rides/steel-vengeance/', icon: 'pi pi-bolt' },
{ id: 3, name: 'Magic Kingdom', path: '/parks/magic-kingdom/', icon: 'pi pi-building' },
])
const countries = ref([
{ code: 'US', name: 'United States', flag: '🇺🇸', count: 89 },
{ code: 'UK', name: 'United Kingdom', flag: '🇬🇧', count: 34 },
{ code: 'DE', name: 'Germany', flag: '🇩🇪', count: 28 },
{ code: 'FR', name: 'France', flag: '🇫🇷', count: 22 },
{ code: 'JP', name: 'Japan', flag: '🇯🇵', count: 18 },
{ code: 'CA', name: 'Canada', flag: '🇨🇦', count: 15 },
])
// User menu items
const userMenuItems = computed(() => [
{
separator: true
},
{
label: user.value?.name || 'Guest',
items: [
{
label: user.value?.email || 'Not signed in',
disabled: true
}
]
},
{
separator: true
},
...(user.value ? [] : [{
label: 'Sign In',
icon: 'pi pi-sign-in',
command: () => showLogin()
}]),
...(user.value ? [{
label: 'Upgrade to Pro',
icon: 'pi pi-star',
command: () => console.log('Upgrade to Pro')
}] : []),
{
separator: true
},
{
label: 'Account',
icon: 'pi pi-user',
command: () => console.log('Account')
},
{
label: 'Billing',
icon: 'pi pi-credit-card',
command: () => console.log('Billing')
},
{
label: 'Notifications',
icon: 'pi pi-bell',
command: () => console.log('Notifications')
},
{
separator: true
},
...(user.value ? [{
label: 'Log out',
icon: 'pi pi-sign-out',
command: () => signOut()
}] : [])
])
// Methods
const applyFilter = (filter: string) => {
const index = activeFilters.value.indexOf(filter)
if (index > -1) {
activeFilters.value.splice(index, 1)
} else {
activeFilters.value.push(filter)
}
// Emit filter change event or update store
emitFilterChange()
}
const filterByCountry = (countryCode: string) => {
selectedCountry.value = selectedCountry.value === countryCode ? '' : countryCode
emitFilterChange()
}
const emitFilterChange = () => {
// Emit custom event that parent components can listen to
const event = new CustomEvent('sidebar-filter-change', {
detail: {
filters: activeFilters.value,
country: selectedCountry.value,
},
})
window.dispatchEvent(event)
}
const toggleParksSubmenu = () => {
// Handle parks submenu toggle
console.log('Toggle parks submenu')
}
const toggleRidesSubmenu = () => {
// Handle rides submenu toggle
console.log('Toggle rides submenu')
}
const showLogin = () => {
// Emit login event
const event = new CustomEvent('show-login')
window.dispatchEvent(event)
}
const signOut = () => {
user.value = null
// Handle sign out logic
}
// Initialize component
onMounted(() => {
// Load user data, recent items, etc.
// This would typically come from a store or API
})
</script>
<style scoped>
.text-decoration-none {
text-decoration: none;
}
</style>

View File

@@ -1,81 +1,236 @@
<template>
<!-- Login Modal -->
<LoginModal
:show="showLogin"
@close="closeAllModals"
@showSignup="switchToSignup"
@success="handleAuthSuccess"
/>
<Dialog
:visible="isVisible"
:modal="true"
:closable="true"
:style="{ width: '450px' }"
class="p-fluid"
@update:visible="handleVisibilityChange"
>
<template #header>
<h3 class="text-xl font-semibold">
{{ currentMode === 'login' ? 'Sign In' : 'Sign Up' }}
</h3>
</template>
<!-- Signup Modal -->
<SignupModal
:show="showSignup"
@close="closeAllModals"
@showLogin="switchToLogin"
@success="handleAuthSuccess"
/>
<div class="space-y-4">
<div class="field">
<label for="email" class="block text-sm font-medium mb-2">Email</label>
<InputText
id="email"
v-model="email"
type="email"
placeholder="Enter your email"
:invalid="!!emailError"
/>
<small v-if="emailError" class="p-error">{{ emailError }}</small>
</div>
<div class="field">
<label for="password" class="block text-sm font-medium mb-2">Password</label>
<InputText
id="password"
v-model="password"
type="password"
placeholder="Enter your password"
:invalid="!!passwordError"
/>
<small v-if="passwordError" class="p-error">{{ passwordError }}</small>
</div>
<div v-if="currentMode === 'signup'" class="field">
<label for="confirmPassword" class="block text-sm font-medium mb-2">Confirm Password</label>
<InputText
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm your password"
:invalid="!!confirmPasswordError"
/>
<small v-if="confirmPasswordError" class="p-error">{{ confirmPasswordError }}</small>
</div>
<div class="flex justify-between items-center pt-4">
<Button
:label="currentMode === 'login' ? 'Sign In' : 'Sign Up'"
@click="handleSubmit"
:loading="isLoading"
class="flex-1 mr-2"
/>
<Button
:label="currentMode === 'login' ? 'Sign Up' : 'Sign In'"
severity="secondary"
@click="toggleMode"
class="flex-1 ml-2"
text
/>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits } from 'vue'
import LoginModal from './LoginModal.vue'
import SignupModal from './SignupModal.vue'
import { ref, computed, watch } from 'vue'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
interface Props {
show?: boolean
show: boolean
initialMode?: 'login' | 'signup'
}
interface Emits {
(e: 'close'): void
(e: 'success', data: { mode: 'login' | 'signup', email: string }): void
}
const props = withDefaults(defineProps<Props>(), {
show: false,
initialMode: 'login',
initialMode: 'login'
})
const emit = defineEmits<{
close: []
success: []
}>()
const emit = defineEmits<Emits>()
// Initialize reactive state
const showLogin = ref(false)
const showSignup = ref(false)
// Reactive state
const currentMode = ref<'login' | 'signup'>(props.initialMode)
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const isLoading = ref(false)
// Define helper functions with explicit function declarations to avoid hoisting issues
function closeAllModals() {
showLogin.value = false
showSignup.value = false
emit('close')
}
// Validation errors
const emailError = ref('')
const passwordError = ref('')
const confirmPasswordError = ref('')
function switchToLogin() {
showSignup.value = false
showLogin.value = true
}
function switchToSignup() {
showLogin.value = false
showSignup.value = true
}
function handleAuthSuccess() {
closeAllModals()
emit('success')
}
// Watch for prop changes to show the appropriate modal
watch(
() => props.show,
(shouldShow) => {
if (shouldShow) {
if (props.initialMode === 'signup') {
switchToSignup()
} else {
switchToLogin()
}
} else {
closeAllModals()
// Computed
const isVisible = computed({
get: () => props.show,
set: (value: boolean) => {
if (!value) {
emit('close')
}
},
{ immediate: true },
)
}
})
// Watch for mode changes
watch(() => props.initialMode, (newMode) => {
currentMode.value = newMode
})
watch(() => props.show, (show) => {
if (show) {
resetForm()
}
})
// Methods
const resetForm = () => {
email.value = ''
password.value = ''
confirmPassword.value = ''
emailError.value = ''
passwordError.value = ''
confirmPasswordError.value = ''
isLoading.value = false
}
const validateForm = () => {
let isValid = true
// Reset errors
emailError.value = ''
passwordError.value = ''
confirmPasswordError.value = ''
// Email validation
if (!email.value) {
emailError.value = 'Email is required'
isValid = false
} else if (!/\S+@\S+\.\S+/.test(email.value)) {
emailError.value = 'Please enter a valid email'
isValid = false
}
// Password validation
if (!password.value) {
passwordError.value = 'Password is required'
isValid = false
} else if (password.value.length < 6) {
passwordError.value = 'Password must be at least 6 characters'
isValid = false
}
// Confirm password validation (only for signup)
if (currentMode.value === 'signup') {
if (!confirmPassword.value) {
confirmPasswordError.value = 'Please confirm your password'
isValid = false
} else if (password.value !== confirmPassword.value) {
confirmPasswordError.value = 'Passwords do not match'
isValid = false
}
}
return isValid
}
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isLoading.value = true
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
// Emit success event
emit('success', {
mode: currentMode.value,
email: email.value
})
// Close modal
emit('close')
} catch (error) {
console.error('Authentication error:', error)
// Handle error (could show toast notification)
} finally {
isLoading.value = false
}
}
const toggleMode = () => {
currentMode.value = currentMode.value === 'login' ? 'signup' : 'login'
// Clear form when switching modes
resetForm()
}
const handleVisibilityChange = (visible: boolean) => {
if (!visible) {
emit('close')
}
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AuthManager'
})
</script>
<style scoped>
.field {
margin-bottom: 1rem;
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>

View File

@@ -26,8 +26,8 @@
</h4>
<p class="text-gray-600 dark:text-gray-400 mb-6">
You need to be signed in to add "{{ searchTerm }}" to ThrillWiki's database. Join
our community of theme park enthusiasts!
You need to be signed in to add "{{ searchTerm }}" to ThrillWiki's database. Join our
community of theme park enthusiasts!
</p>
</div>
@@ -135,9 +135,7 @@
<!-- Alternative Options -->
<div class="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
Or continue exploring ThrillWiki
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Or continue exploring ThrillWiki</p>
<button
@click="handleBrowseExisting"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium transition-colors"
@@ -150,26 +148,26 @@
<script setup lang="ts">
interface Props {
searchTerm: string;
searchTerm: string
}
const props = defineProps<Props>();
const props = defineProps<Props>()
const emit = defineEmits<{
login: [];
signup: [];
browse: [];
}>();
login: []
signup: []
browse: []
}>()
const handleLogin = () => {
emit("login");
};
emit('login')
}
const handleSignup = () => {
emit("signup");
};
emit('signup')
}
const handleBrowseExisting = () => {
emit("browse");
};
emit('browse')
}
</script>

View File

@@ -27,12 +27,7 @@
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
@@ -102,86 +97,82 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { EntitySuggestion } from "../../services/api";
import { computed } from 'vue'
import type { EntitySuggestion } from '../../services/api'
interface Props {
suggestion: EntitySuggestion;
suggestion: EntitySuggestion
}
const props = defineProps<Props>();
const props = defineProps<Props>()
const emit = defineEmits<{
select: [suggestion: EntitySuggestion];
}>();
select: [suggestion: EntitySuggestion]
}>()
// Entity type configurations
const entityTypeConfig = {
park: {
label: "Park",
badgeClass: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
icon: "BuildingStorefrontIcon",
label: 'Park',
badgeClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
icon: 'BuildingStorefrontIcon',
},
ride: {
label: "Ride",
badgeClass: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
icon: "SparklesIcon",
label: 'Ride',
badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
icon: 'SparklesIcon',
},
company: {
label: "Company",
badgeClass: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
icon: "BuildingOfficeIcon",
label: 'Company',
badgeClass: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
icon: 'BuildingOfficeIcon',
},
};
}
// Computed properties
const entityTypeLabel = computed(
() => entityTypeConfig[props.suggestion.entity_type]?.label || "Entity"
);
() => entityTypeConfig[props.suggestion.entity_type]?.label || 'Entity',
)
const entityTypeBadgeClasses = computed(() => {
const baseClasses =
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium";
const baseClasses = 'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium'
const typeClasses =
entityTypeConfig[props.suggestion.entity_type]?.badgeClass ||
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200";
return `${baseClasses} ${typeClasses}`;
});
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
return `${baseClasses} ${typeClasses}`
})
const confidenceLabel = computed(() => {
const score = props.suggestion.confidence_score;
if (score >= 0.8) return "High Match";
if (score >= 0.6) return "Good Match";
if (score >= 0.4) return "Possible Match";
return "Low Match";
});
const score = props.suggestion.confidence_score
if (score >= 0.8) return 'High Match'
if (score >= 0.6) return 'Good Match'
if (score >= 0.4) return 'Possible Match'
return 'Low Match'
})
const confidenceClasses = computed(() => {
const score = props.suggestion.confidence_score;
if (score >= 0.8)
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
if (score >= 0.6)
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300";
if (score >= 0.4)
return "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300";
return "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300";
});
const score = props.suggestion.confidence_score
if (score >= 0.8) return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
if (score >= 0.6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
if (score >= 0.4) return 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
})
// Simple icon components
const entityIcon = computed(() => {
const type = props.suggestion.entity_type;
const type = props.suggestion.entity_type
// Return appropriate icon component name or a default SVG
if (type === "park") return "BuildingStorefrontIcon";
if (type === "ride") return "SparklesIcon";
if (type === "company") return "BuildingOfficeIcon";
return "QuestionMarkCircleIcon";
});
if (type === 'park') return 'BuildingStorefrontIcon'
if (type === 'ride') return 'SparklesIcon'
if (type === 'company') return 'BuildingOfficeIcon'
return 'QuestionMarkCircleIcon'
})
// Event handlers
const handleSelect = () => {
emit("select", props.suggestion);
};
emit('select', props.suggestion)
}
</script>
<style scoped>

View File

@@ -21,84 +21,84 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, readonly } from "vue";
import { useRouter } from "vue-router";
import { useAuth } from "../../composables/useAuth";
import { ThrillWikiApi, type EntitySuggestion } from "../../services/api";
import EntitySuggestionModal from "./EntitySuggestionModal.vue";
import AuthManager from "../auth/AuthManager.vue";
import { ref, computed, watch, readonly } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '../../composables/useAuth'
import { ThrillWikiApi, type EntitySuggestion } from '../../services/api'
import EntitySuggestionModal from './EntitySuggestionModal.vue'
import AuthManager from '../auth/AuthManager.vue'
interface Props {
searchTerm: string;
show?: boolean;
entityTypes?: string[];
parkContext?: string;
maxSuggestions?: number;
searchTerm: string
show?: boolean
entityTypes?: string[]
parkContext?: string
maxSuggestions?: number
}
const props = withDefaults(defineProps<Props>(), {
show: false,
entityTypes: () => ["park", "ride", "company"],
entityTypes: () => ['park', 'ride', 'company'],
maxSuggestions: 5,
});
})
const emit = defineEmits<{
close: [];
entitySelected: [entity: EntitySuggestion];
entityAdded: [entityType: string, name: string];
error: [message: string];
}>();
close: []
entitySelected: [entity: EntitySuggestion]
entityAdded: [entityType: string, name: string]
error: [message: string]
}>()
// Dependencies
const router = useRouter();
const { user, isAuthenticated, login, signup } = useAuth();
const api = new ThrillWikiApi();
const router = useRouter()
const { user, isAuthenticated, login, signup } = useAuth()
const api = new ThrillWikiApi()
// Reactive state
const showModal = ref(props.show);
const suggestions = ref<EntitySuggestion[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const showModal = ref(props.show)
const suggestions = ref<EntitySuggestion[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Authentication modal state
const showAuthModal = ref(false);
const authMode = ref<'login' | 'signup'>('login');
const showAuthModal = ref(false)
const authMode = ref<'login' | 'signup'>('login')
// Computed properties
const hasValidSearchTerm = computed(() => {
return props.searchTerm && props.searchTerm.trim().length > 0;
});
return props.searchTerm && props.searchTerm.trim().length > 0
})
// Watch for prop changes
watch(
() => props.show,
(newShow) => {
showModal.value = newShow;
showModal.value = newShow
if (newShow && hasValidSearchTerm.value) {
performFuzzySearch();
performFuzzySearch()
}
},
{ immediate: true }
);
{ immediate: true },
)
watch(
() => props.searchTerm,
(newTerm) => {
if (showModal.value && newTerm && newTerm.trim().length > 0) {
performFuzzySearch();
performFuzzySearch()
}
}
);
},
)
// Methods
const performFuzzySearch = async () => {
if (!hasValidSearchTerm.value) {
suggestions.value = [];
return;
suggestions.value = []
return
}
loading.value = true;
error.value = null;
loading.value = true
error.value = null
try {
const response = await api.entitySearch.fuzzySearch({
@@ -107,121 +107,121 @@ const performFuzzySearch = async () => {
parkContext: props.parkContext,
maxResults: props.maxSuggestions,
minConfidence: 0.3,
});
})
suggestions.value = response.suggestions || [];
suggestions.value = response.suggestions || []
} catch (err) {
console.error("Fuzzy search failed:", err);
error.value = "Failed to search for similar entities. Please try again.";
suggestions.value = [];
emit("error", error.value);
console.error('Fuzzy search failed:', err)
error.value = 'Failed to search for similar entities. Please try again.'
suggestions.value = []
emit('error', error.value)
} finally {
loading.value = false;
loading.value = false
}
};
}
const handleClose = () => {
showModal.value = false;
emit("close");
};
showModal.value = false
emit('close')
}
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit("entitySelected", suggestion);
handleClose();
emit('entitySelected', suggestion)
handleClose()
// Navigate to the selected entity
navigateToEntity(suggestion);
};
navigateToEntity(suggestion)
}
const handleAddEntity = async (entityType: string, name: string) => {
try {
// Emit event for parent to handle
emit("entityAdded", entityType, name);
emit('entityAdded', entityType, name)
// For now, just close the modal
// In a real implementation, this might navigate to an add entity form
handleClose();
handleClose()
// You could also show a success message here
console.log(`Entity creation initiated: ${entityType} - ${name}`);
console.log(`Entity creation initiated: ${entityType} - ${name}`)
} catch (err) {
console.error("Failed to initiate entity creation:", err);
error.value = "Failed to initiate entity creation. Please try again.";
emit("error", error.value);
console.error('Failed to initiate entity creation:', err)
error.value = 'Failed to initiate entity creation. Please try again.'
emit('error', error.value)
}
};
}
const handleLogin = () => {
authMode.value = 'login';
showAuthModal.value = true;
};
authMode.value = 'login'
showAuthModal.value = true
}
const handleSignup = () => {
authMode.value = 'signup';
showAuthModal.value = true;
};
authMode.value = 'signup'
showAuthModal.value = true
}
// Authentication modal handlers
const handleAuthClose = () => {
showAuthModal.value = false;
};
showAuthModal.value = false
}
const handleAuthSuccess = () => {
showAuthModal.value = false;
showAuthModal.value = false
// Optionally refresh suggestions now that user is authenticated
if (hasValidSearchTerm.value && showModal.value) {
performFuzzySearch();
performFuzzySearch()
}
};
}
const navigateToEntity = (entity: EntitySuggestion) => {
try {
let route = "";
let route = ''
switch (entity.entity_type) {
case "park":
route = `/parks/${entity.slug}`;
break;
case "ride":
case 'park':
route = `/parks/${entity.slug}`
break
case 'ride':
if (entity.park_slug) {
route = `/parks/${entity.park_slug}/rides/${entity.slug}`;
route = `/parks/${entity.park_slug}/rides/${entity.slug}`
} else {
route = `/rides/${entity.slug}`;
route = `/rides/${entity.slug}`
}
break;
case "company":
route = `/companies/${entity.slug}`;
break;
break
case 'company':
route = `/companies/${entity.slug}`
break
default:
console.warn(`Unknown entity type: ${entity.entity_type}`);
return;
console.warn(`Unknown entity type: ${entity.entity_type}`)
return
}
router.push(route);
router.push(route)
} catch (err) {
console.error("Failed to navigate to entity:", err);
error.value = "Failed to navigate to the selected entity.";
emit("error", error.value);
console.error('Failed to navigate to entity:', err)
error.value = 'Failed to navigate to the selected entity.'
emit('error', error.value)
}
};
}
// Public methods for external control
const show = () => {
showModal.value = true;
showModal.value = true
if (hasValidSearchTerm.value) {
performFuzzySearch();
performFuzzySearch()
}
};
}
const hide = () => {
showModal.value = false;
};
showModal.value = false
}
const refresh = () => {
if (showModal.value && hasValidSearchTerm.value) {
performFuzzySearch();
performFuzzySearch()
}
};
}
// Expose methods for parent components
defineExpose({
@@ -231,5 +231,5 @@ defineExpose({
suggestions: readonly(suggestions),
loading: readonly(loading),
error: readonly(error),
});
})
</script>

View File

@@ -36,9 +36,7 @@
class="flex items-center justify-between p-6 pb-4 border-b border-gray-200 dark:border-gray-700"
>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Entity Not Found
</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Entity Not Found</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
We couldn't find "{{ searchTerm }}" but here are some suggestions
</p>
@@ -47,12 +45,7 @@
@click="$emit('close')"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -89,8 +82,7 @@
<!-- Authenticated User - Add Entity -->
<div v-if="isAuthenticated" class="space-y-4">
<p class="text-gray-600 dark:text-gray-400">
You can help improve ThrillWiki by adding this entity to our
database.
You can help improve ThrillWiki by adding this entity to our database.
</p>
<div class="flex gap-3">
<button
@@ -133,12 +125,8 @@
class="absolute inset-0 bg-white/80 dark:bg-gray-800/80 flex items-center justify-center rounded-2xl"
>
<div class="flex items-center gap-3">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"
></div>
<span class="text-gray-700 dark:text-gray-300">{{
loadingMessage
}}</span>
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-gray-700 dark:text-gray-300">{{ loadingMessage }}</span>
</div>
</div>
</div>
@@ -150,77 +138,77 @@
</template>
<script setup lang="ts">
import { ref, computed, toRefs, onUnmounted, watch } from "vue";
import type { EntitySuggestion } from "../../services/api";
import EntitySuggestionCard from "./EntitySuggestionCard.vue";
import AuthPrompt from "./AuthPrompt.vue";
import { ref, computed, toRefs, onUnmounted, watch } from 'vue'
import type { EntitySuggestion } from '../../services/api'
import EntitySuggestionCard from './EntitySuggestionCard.vue'
import AuthPrompt from './AuthPrompt.vue'
interface Props {
show: boolean;
searchTerm: string;
suggestions: EntitySuggestion[];
isAuthenticated: boolean;
closeOnBackdrop?: boolean;
show: boolean
searchTerm: string
suggestions: EntitySuggestion[]
isAuthenticated: boolean
closeOnBackdrop?: boolean
}
const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true,
});
})
const emit = defineEmits<{
close: [];
selectSuggestion: [suggestion: EntitySuggestion];
addEntity: [entityType: string, name: string];
login: [];
signup: [];
}>();
close: []
selectSuggestion: [suggestion: EntitySuggestion]
addEntity: [entityType: string, name: string]
login: []
signup: []
}>()
// Loading state
const loading = ref(false);
const loadingMessage = ref("");
const loading = ref(false)
const loadingMessage = ref('')
const handleBackdropClick = (event: MouseEvent) => {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
emit("close");
emit('close')
}
};
}
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit("selectSuggestion", suggestion);
};
emit('selectSuggestion', suggestion)
}
const handleAddEntity = async (entityType: string) => {
loading.value = true;
loadingMessage.value = `Adding ${entityType}...`;
loading.value = true
loadingMessage.value = `Adding ${entityType}...`
try {
emit("addEntity", entityType, props.searchTerm);
emit('addEntity', entityType, props.searchTerm)
} finally {
loading.value = false;
loadingMessage.value = "";
loading.value = false
loadingMessage.value = ''
}
};
}
const handleLogin = () => {
emit("login");
};
emit('login')
}
const handleSignup = () => {
emit("signup");
};
emit('signup')
}
// Prevent body scroll when modal is open
const { show } = toRefs(props);
const { show } = toRefs(props)
watch(show, (isShown) => {
if (isShown) {
document.body.style.overflow = "hidden";
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = "";
document.body.style.overflow = ''
}
});
})
// Clean up on unmount
onUnmounted(() => {
document.body.style.overflow = "";
});
document.body.style.overflow = ''
})
</script>

View File

@@ -4,4 +4,4 @@ export { default as EntitySuggestionCard } from './EntitySuggestionCard.vue'
export { default as AuthPrompt } from './AuthPrompt.vue'
// Main integration component
export { default as EntitySuggestionManager } from './EntitySuggestionManager.vue'
export { default as EntitySuggestionManager } from './EntitySuggestionManager.vue'

View File

@@ -7,13 +7,11 @@
}"
>
<!-- Filter icon -->
<Icon :name="icon" class="w-4 h-4 text-blue-600 dark:text-blue-300 flex-shrink-0" />
<i :class="`pi ${getIconClass(icon)} w-4 h-4 text-blue-600 dark:text-blue-300 flex-shrink-0`" />
<!-- Filter label and value -->
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-blue-800 dark:text-blue-200 font-medium truncate">
{{ label }}:
</span>
<span class="text-blue-800 dark:text-blue-200 font-medium truncate"> {{ label }}: </span>
<span class="text-blue-700 dark:text-blue-300 truncate">
{{ displayValue }}
</span>
@@ -34,68 +32,84 @@
class="flex items-center justify-center w-5 h-5 rounded-full hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors group"
:aria-label="`Remove ${label} filter`"
>
<Icon
name="x"
class="w-3 h-3 text-blue-600 dark:text-blue-300 group-hover:text-blue-800 dark:group-hover:text-blue-100"
/>
<i class="pi pi-times w-3 h-3 text-blue-600 dark:text-blue-300 group-hover:text-blue-800 dark:group-hover:text-blue-100" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import Icon from "@/components/ui/Icon.vue";
import { computed } from 'vue'
interface Props {
label: string;
value: any;
icon?: string;
count?: number;
removable?: boolean;
disabled?: boolean;
formatValue?: (value: any) => string;
label: string
value: any
icon?: string
count?: number
removable?: boolean
disabled?: boolean
formatValue?: (value: any) => string
}
const props = withDefaults(defineProps<Props>(), {
icon: "filter",
icon: 'filter',
removable: true,
disabled: false,
});
})
defineEmits<{
remove: [];
}>();
remove: []
}>()
// Computed
const displayValue = computed(() => {
if (props.formatValue) {
return props.formatValue(props.value);
return props.formatValue(props.value)
}
if (Array.isArray(props.value)) {
if (props.value.length === 1) {
return String(props.value[0]);
return String(props.value[0])
} else if (props.value.length <= 3) {
return props.value.join(", ");
return props.value.join(', ')
} else {
return `${props.value.slice(0, 2).join(", ")} +${props.value.length - 2} more`;
return `${props.value.slice(0, 2).join(', ')} +${props.value.length - 2} more`
}
}
if (typeof props.value === "object" && props.value !== null) {
if (typeof props.value === 'object' && props.value !== null) {
// Handle range objects
if ("min" in props.value && "max" in props.value) {
return `${props.value.min} - ${props.value.max}`;
if ('min' in props.value && 'max' in props.value) {
return `${props.value.min} - ${props.value.max}`
}
// Handle date range objects
if ("start" in props.value && "end" in props.value) {
return `${props.value.start} to ${props.value.end}`;
if ('start' in props.value && 'end' in props.value) {
return `${props.value.start} to ${props.value.end}`
}
return JSON.stringify(props.value);
return JSON.stringify(props.value)
}
return String(props.value);
});
return String(props.value)
})
// Icon mapping function
const getIconClass = (iconName: string): string => {
const iconMap: Record<string, string> = {
'filter': 'pi-filter',
'search': 'pi-search',
'calendar': 'pi-calendar',
'map-pin': 'pi-map-marker',
'tag': 'pi-tag',
'users': 'pi-users',
'building': 'pi-building',
'activity': 'pi-chart-line',
'globe': 'pi-globe',
'x': 'pi-times',
'check': 'pi-check',
'plus': 'pi-plus',
'minus': 'pi-minus',
}
return iconMap[iconName] || 'pi-circle'
}
</script>
<style scoped>

View File

@@ -23,9 +23,7 @@
:disabled="disabled"
:placeholder="startPlaceholder"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<Icon name="calendar" class="w-4 h-4 text-gray-400" />
</div>
</div>
@@ -47,9 +45,7 @@
:disabled="disabled"
:placeholder="endPlaceholder"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<Icon name="calendar" class="w-4 h-4 text-gray-400" />
</div>
</div>
@@ -65,9 +61,7 @@
<!-- Quick preset buttons -->
<div v-if="showPresets && presets.length > 0" class="mt-3">
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick Select
</div>
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Quick Select</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
@@ -75,9 +69,8 @@
@click="applyPreset(preset)"
class="px-3 py-1 text-xs rounded-full border border-gray-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900 dark:border-blue-600 dark:text-blue-200': isActivePreset(
preset
),
'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900 dark:border-blue-600 dark:text-blue-200':
isActivePreset(preset),
}"
:disabled="disabled"
>
@@ -109,259 +102,257 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import Icon from "@/components/ui/Icon.vue";
import { ref, computed, watch } from 'vue'
import Icon from '@/components/ui/Icon.vue'
interface DatePreset {
label: string;
startDate: string | (() => string);
endDate: string | (() => string);
label: string
startDate: string | (() => string)
endDate: string | (() => string)
}
interface Props {
label: string;
value?: [string, string];
minDate?: string;
maxDate?: string;
startPlaceholder?: string;
endPlaceholder?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
showPresets?: boolean;
helperText?: string;
validateRange?: boolean;
label: string
value?: [string, string]
minDate?: string
maxDate?: string
startPlaceholder?: string
endPlaceholder?: string
required?: boolean
disabled?: boolean
clearable?: boolean
showPresets?: boolean
helperText?: string
validateRange?: boolean
}
const props = withDefaults(defineProps<Props>(), {
startPlaceholder: "Start date",
endPlaceholder: "End date",
startPlaceholder: 'Start date',
endPlaceholder: 'End date',
required: false,
disabled: false,
clearable: true,
showPresets: true,
validateRange: true,
});
})
const emit = defineEmits<{
update: [value: [string, string] | undefined];
}>();
update: [value: [string, string] | undefined]
}>()
// Local state
const startDate = ref(props.value?.[0] || "");
const endDate = ref(props.value?.[1] || "");
const validationMessage = ref("");
const startDate = ref(props.value?.[0] || '')
const endDate = ref(props.value?.[1] || '')
const validationMessage = ref('')
// Computed
const hasSelection = computed(() => {
return Boolean(startDate.value && endDate.value);
});
return Boolean(startDate.value && endDate.value)
})
const duration = computed(() => {
if (!startDate.value || !endDate.value) return null;
if (!startDate.value || !endDate.value) return null
const start = new Date(startDate.value);
const end = new Date(endDate.value);
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const start = new Date(startDate.value)
const end = new Date(endDate.value)
const diffTime = Math.abs(end.getTime() - start.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 1) return "1 day";
if (diffDays < 7) return `${diffDays} days`;
if (diffDays < 30) return `${Math.round(diffDays / 7)} weeks`;
if (diffDays < 365) return `${Math.round(diffDays / 30)} months`;
return `${Math.round(diffDays / 365)} years`;
});
if (diffDays === 1) return '1 day'
if (diffDays < 7) return `${diffDays} days`
if (diffDays < 30) return `${Math.round(diffDays / 7)} weeks`
if (diffDays < 365) return `${Math.round(diffDays / 30)} months`
return `${Math.round(diffDays / 365)} years`
})
const presets = computed((): DatePreset[] => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const lastWeek = new Date(today);
lastWeek.setDate(lastWeek.getDate() - 7);
const lastWeek = new Date(today)
lastWeek.setDate(lastWeek.getDate() - 7)
const lastMonth = new Date(today);
lastMonth.setMonth(lastMonth.getMonth() - 1);
const lastMonth = new Date(today)
lastMonth.setMonth(lastMonth.getMonth() - 1)
const lastYear = new Date(today);
lastYear.setFullYear(lastYear.getFullYear() - 1);
const lastYear = new Date(today)
lastYear.setFullYear(lastYear.getFullYear() - 1)
const thisYear = new Date(today.getFullYear(), 0, 1);
const thisYear = new Date(today.getFullYear(), 0, 1)
return [
{
label: "Today",
label: 'Today',
startDate: () => formatDate(today),
endDate: () => formatDate(today),
},
{
label: "Yesterday",
label: 'Yesterday',
startDate: () => formatDate(yesterday),
endDate: () => formatDate(yesterday),
},
{
label: "Last 7 days",
label: 'Last 7 days',
startDate: () => formatDate(lastWeek),
endDate: () => formatDate(today),
},
{
label: "Last 30 days",
label: 'Last 30 days',
startDate: () => formatDate(lastMonth),
endDate: () => formatDate(today),
},
{
label: "This year",
label: 'This year',
startDate: () => formatDate(thisYear),
endDate: () => formatDate(today),
},
{
label: "Last year",
label: 'Last year',
startDate: () => formatDate(new Date(lastYear.getFullYear(), 0, 1)),
endDate: () => formatDate(new Date(lastYear.getFullYear(), 11, 31)),
},
];
});
]
})
// Methods
const formatDate = (date: Date): string => {
return date.toISOString().split("T")[0];
};
return date.toISOString().split('T')[0]
}
const formatDateRange = (start: string, end: string): string => {
if (!start || !end) return "";
if (!start || !end) return ''
const startDate = new Date(start);
const endDate = new Date(end);
const startDate = new Date(start)
const endDate = new Date(end)
const formatOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
year: 'numeric',
month: 'short',
day: 'numeric',
}
if (start === end) {
return startDate.toLocaleDateString(undefined, formatOptions);
return startDate.toLocaleDateString(undefined, formatOptions)
}
return `${startDate.toLocaleDateString(
undefined,
formatOptions
)} - ${endDate.toLocaleDateString(undefined, formatOptions)}`;
};
formatOptions,
)} - ${endDate.toLocaleDateString(undefined, formatOptions)}`
}
const validateDates = (): boolean => {
validationMessage.value = "";
validationMessage.value = ''
if (!props.validateRange) return true;
if (!props.validateRange) return true
if (startDate.value && endDate.value) {
const start = new Date(startDate.value);
const end = new Date(endDate.value);
const start = new Date(startDate.value)
const end = new Date(endDate.value)
if (start > end) {
validationMessage.value = "Start date cannot be after end date";
return false;
validationMessage.value = 'Start date cannot be after end date'
return false
}
}
if (props.minDate && startDate.value) {
const start = new Date(startDate.value);
const min = new Date(props.minDate);
const start = new Date(startDate.value)
const min = new Date(props.minDate)
if (start < min) {
validationMessage.value = `Date cannot be before ${formatDateRange(
props.minDate,
props.minDate
)}`;
return false;
props.minDate,
)}`
return false
}
}
if (props.maxDate && endDate.value) {
const end = new Date(endDate.value);
const max = new Date(props.maxDate);
const end = new Date(endDate.value)
const max = new Date(props.maxDate)
if (end > max) {
validationMessage.value = `Date cannot be after ${formatDateRange(
props.maxDate,
props.maxDate
)}`;
return false;
props.maxDate,
)}`
return false
}
}
return true;
};
return true
}
const handleStartDateChange = (event: Event) => {
const target = event.target as HTMLInputElement;
startDate.value = target.value;
const target = event.target as HTMLInputElement
startDate.value = target.value
// Auto-adjust end date if it's before start date
if (endDate.value && startDate.value > endDate.value) {
endDate.value = startDate.value;
endDate.value = startDate.value
}
};
}
const handleEndDateChange = (event: Event) => {
const target = event.target as HTMLInputElement;
endDate.value = target.value;
const target = event.target as HTMLInputElement
endDate.value = target.value
// Auto-adjust start date if it's after end date
if (startDate.value && endDate.value < startDate.value) {
startDate.value = endDate.value;
startDate.value = endDate.value
}
};
}
const emitChange = () => {
if (!validateDates()) return;
if (!validateDates()) return
const hasValidRange = Boolean(startDate.value && endDate.value);
emit("update", hasValidRange ? [startDate.value, endDate.value] : undefined);
};
const hasValidRange = Boolean(startDate.value && endDate.value)
emit('update', hasValidRange ? [startDate.value, endDate.value] : undefined)
}
const applyPreset = (preset: DatePreset) => {
const start =
typeof preset.startDate === "function" ? preset.startDate() : preset.startDate;
const end = typeof preset.endDate === "function" ? preset.endDate() : preset.endDate;
const start = typeof preset.startDate === 'function' ? preset.startDate() : preset.startDate
const end = typeof preset.endDate === 'function' ? preset.endDate() : preset.endDate
startDate.value = start;
endDate.value = end;
emitChange();
};
startDate.value = start
endDate.value = end
emitChange()
}
const isActivePreset = (preset: DatePreset): boolean => {
if (!hasSelection.value) return false;
if (!hasSelection.value) return false
const start =
typeof preset.startDate === "function" ? preset.startDate() : preset.startDate;
const end = typeof preset.endDate === "function" ? preset.endDate() : preset.endDate;
const start = typeof preset.startDate === 'function' ? preset.startDate() : preset.startDate
const end = typeof preset.endDate === 'function' ? preset.endDate() : preset.endDate
return startDate.value === start && endDate.value === end;
};
return startDate.value === start && endDate.value === end
}
const clearDates = () => {
startDate.value = "";
endDate.value = "";
validationMessage.value = "";
emit("update", undefined);
};
startDate.value = ''
endDate.value = ''
validationMessage.value = ''
emit('update', undefined)
}
// Watch for prop changes
watch(
() => props.value,
(newValue) => {
if (newValue) {
startDate.value = newValue[0] || "";
endDate.value = newValue[1] || "";
startDate.value = newValue[0] || ''
endDate.value = newValue[1] || ''
} else {
startDate.value = "";
endDate.value = "";
startDate.value = ''
endDate.value = ''
}
validationMessage.value = "";
validationMessage.value = ''
},
{ immediate: true }
);
{ immediate: true },
)
</script>
<style scoped>
@@ -401,17 +392,17 @@ watch(
@apply text-gray-500 dark:text-gray-400;
}
.date-input[type="date"]::-webkit-input-placeholder {
.date-input[type='date']::-webkit-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Firefox */
.date-input[type="date"]::-moz-placeholder {
.date-input[type='date']::-moz-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Edge */
.date-input[type="date"]::-ms-input-placeholder {
.date-input[type='date']::-ms-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
</style>

View File

@@ -10,10 +10,11 @@
{{ title }}
</h3>
<Icon
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
:class="{ 'rotate-180': isExpanded }"
<i
:class="[
'w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform',
isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'
]"
/>
</button>
@@ -39,19 +40,18 @@
</template>
<script setup lang="ts">
import Icon from "@/components/ui/Icon.vue";
interface Props {
id: string;
title: string;
isExpanded: boolean;
id: string
title: string
isExpanded: boolean
}
defineProps<Props>();
defineProps<Props>()
defineEmits<{
toggle: [];
}>();
toggle: []
}>()
</script>
<style scoped>

View File

@@ -1,138 +1,112 @@
<template>
<div
class="preset-item group flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
<Card
class="preset-item group cursor-pointer transition-all duration-200 hover:shadow-md border-2"
:class="{
'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-600': isActive,
'bg-primary/10 border-primary': isActive,
'cursor-pointer': !disabled,
'opacity-50 cursor-not-allowed': disabled,
}"
@click="!disabled && $emit('select')"
>
<!-- Preset info -->
<div class="flex-1 min-w-0">
<!-- Name and description -->
<div class="flex items-center gap-2 mb-1">
<h4 class="font-medium text-gray-900 dark:text-white truncate">
{{ preset.name }}
</h4>
<span
v-if="isDefault"
class="px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-full"
>
Default
</span>
<span
v-if="isGlobal"
class="px-2 py-0.5 text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300 rounded-full"
>
Global
</span>
</div>
<template #content>
<div class="p-3">
<div class="flex items-center justify-between">
<!-- Preset info -->
<div class="flex-1 min-w-0">
<!-- Name and badges -->
<div class="flex items-center gap-2 mb-1">
<h3 class="text-base font-medium truncate">
{{ preset.name }}
</h3>
<Badge
v-if="isDefault"
severity="secondary"
class="text-xs"
>
Default
</Badge>
<Badge
v-if="isGlobal"
severity="success"
class="text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-200 border-green-200 dark:border-green-700"
>
Global
</Badge>
</div>
<!-- Description -->
<p
v-if="preset.description"
class="text-sm text-gray-600 dark:text-gray-400 truncate"
>
{{ preset.description }}
</p>
<!-- Description -->
<p v-if="preset.description" class="text-sm truncate mb-2 text-muted-foreground">
{{ preset.description }}
</p>
<!-- Filter count and last used -->
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<Icon name="filter" class="w-3 h-3" />
{{ filterCount }} {{ filterCount === 1 ? "filter" : "filters" }}
</span>
<span v-if="preset.lastUsed" class="flex items-center gap-1">
<Icon name="clock" class="w-3 h-3" />
{{ formatLastUsed(preset.lastUsed) }}
</span>
</div>
</div>
<!-- Filter count and last used -->
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<span class="flex items-center gap-1">
<i class="pi pi-filter w-3 h-3" />
{{ filterCount }} {{ filterCount === 1 ? 'filter' : 'filters' }}
</span>
<span v-if="preset.lastUsed" class="flex items-center gap-1">
<i class="pi pi-clock w-3 h-3" />
{{ formatLastUsed(preset.lastUsed) }}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<!-- Star/favorite button -->
<button
v-if="!isDefault && showFavorite"
@click.stop="$emit('toggle-favorite')"
class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
:class="{
'text-yellow-500': preset.isFavorite,
'text-gray-400 dark:text-gray-500': !preset.isFavorite,
}"
:aria-label="preset.isFavorite ? 'Remove from favorites' : 'Add to favorites'"
>
<Icon :name="preset.isFavorite ? 'star-filled' : 'star'" class="w-4 h-4" />
</button>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<!-- Star/favorite button -->
<Button
v-if="!isDefault && showFavorite"
@click.stop="$emit('toggle-favorite')"
text
size="small"
class="p-1 h-auto w-auto"
:class="{
'text-yellow-500': preset.isFavorite,
'text-muted-foreground': !preset.isFavorite,
}"
:aria-label="preset.isFavorite ? 'Remove from favorites' : 'Add to favorites'"
>
<i class="pi pi-star-fill w-4 h-4" :class="{ 'text-yellow-500': preset.isFavorite }"></i>
</Button>
<!-- More actions menu -->
<div class="relative" v-if="showActions">
<button
@click.stop="showMenu = !showMenu"
class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors text-gray-400 dark:text-gray-500"
:aria-label="'More actions for ' + preset.name"
>
<Icon name="more-vertical" class="w-4 h-4" />
</button>
<!-- Dropdown menu -->
<div
v-if="showMenu"
class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10"
@click.stop
>
<button
v-if="!isDefault"
@click="
$emit('rename');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Icon name="edit" class="w-4 h-4 inline mr-2" />
Rename
</button>
<button
@click="
$emit('duplicate');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Icon name="copy" class="w-4 h-4 inline mr-2" />
Duplicate
</button>
<button
v-if="!isDefault"
@click="
$emit('delete');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Icon name="trash" class="w-4 h-4 inline mr-2" />
Delete
</button>
<!-- More actions menu -->
<Menu v-if="showActions" ref="menu" :model="menuItems" :popup="true">
<template #start>
<Button
text
size="small"
class="p-1 h-auto w-auto text-muted-foreground"
:aria-label="'More actions for ' + preset.name"
@click.stop="toggleMenu"
>
<i class="pi pi-ellipsis-v w-4 h-4"></i>
</Button>
</template>
</Menu>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import Icon from "@/components/ui/Icon.vue";
import type { FilterPreset } from "@/types/filters";
import { computed, ref } from 'vue'
import Card from 'primevue/card'
import Badge from 'primevue/badge'
import Button from 'primevue/button'
import Menu from 'primevue/menu'
import type { FilterPreset } from '@/types/filters'
interface Props {
preset: FilterPreset;
isActive?: boolean;
isDefault?: boolean;
isGlobal?: boolean;
disabled?: boolean;
showFavorite?: boolean;
showActions?: boolean;
preset: FilterPreset
isActive?: boolean
isDefault?: boolean
isGlobal?: boolean
disabled?: boolean
showFavorite?: boolean
showActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -142,90 +116,94 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
showFavorite: true,
showActions: true,
});
})
defineEmits<{
select: [];
"toggle-favorite": [];
rename: [];
duplicate: [];
delete: [];
}>();
const emit = defineEmits<{
select: []
'toggle-favorite': []
rename: []
duplicate: []
delete: []
}>()
// Local state
const showMenu = ref(false);
// Menu ref and items
const menu = ref()
const menuItems = computed(() => {
const items = []
if (!props.isDefault) {
items.push({
label: 'Rename',
icon: 'pi pi-pencil',
command: () => emit('rename')
})
}
items.push({
label: 'Duplicate',
icon: 'pi pi-copy',
command: () => emit('duplicate')
})
if (!props.isDefault) {
items.push({
separator: true
})
items.push({
label: 'Delete',
icon: 'pi pi-trash',
class: 'text-red-500',
command: () => emit('delete')
})
}
return items
})
const toggleMenu = (event: Event) => {
menu.value.toggle(event)
}
// Computed
const filterCount = computed(() => {
let count = 0;
const filters = props.preset.filters;
let count = 0
const filters = props.preset.filters
if (filters.search?.trim()) count++;
if (filters.categories?.length) count++;
if (filters.manufacturers?.length) count++;
if (filters.designers?.length) count++;
if (filters.parks?.length) count++;
if (filters.status?.length) count++;
if (filters.opened?.start || filters.opened?.end) count++;
if (filters.closed?.start || filters.closed?.end) count++;
if (filters.heightRange?.min !== undefined || filters.heightRange?.max !== undefined)
count++;
if (filters.speedRange?.min !== undefined || filters.speedRange?.max !== undefined)
count++;
if (
filters.durationRange?.min !== undefined ||
filters.durationRange?.max !== undefined
)
count++;
if (
filters.capacityRange?.min !== undefined ||
filters.capacityRange?.max !== undefined
)
count++;
if (filters.search?.trim()) count++
if (filters.categories?.length) count++
if (filters.manufacturers?.length) count++
if (filters.designers?.length) count++
if (filters.parks?.length) count++
if (filters.status?.length) count++
if (filters.opened?.start || filters.opened?.end) count++
if (filters.closed?.start || filters.closed?.end) count++
if (filters.heightRange?.min !== undefined || filters.heightRange?.max !== undefined) count++
if (filters.speedRange?.min !== undefined || filters.speedRange?.max !== undefined) count++
if (filters.durationRange?.min !== undefined || filters.durationRange?.max !== undefined) count++
if (filters.capacityRange?.min !== undefined || filters.capacityRange?.max !== undefined) count++
return count;
});
return count
})
// Methods
const formatLastUsed = (lastUsed: string): string => {
const date = new Date(lastUsed);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const date = new Date(lastUsed)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
return "Today";
return 'Today'
} else if (diffDays === 1) {
return "Yesterday";
return 'Yesterday'
} else if (diffDays < 7) {
return `${diffDays} days ago`;
return `${diffDays} days ago`
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
const weeks = Math.floor(diffDays / 7)
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`
} else {
return date.toLocaleDateString();
return date.toLocaleDateString()
}
};
// Close menu when clicking outside
const handleClickOutside = () => {
showMenu.value = false;
};
// Add/remove event listener for clicking outside
if (typeof window !== "undefined") {
document.addEventListener("click", handleClickOutside);
}
</script>
<style scoped>
@reference "tailwindcss";
.preset-item {
@apply transition-all duration-200;
}
.preset-item:hover {
@apply shadow-sm;
}
</style>

View File

@@ -6,9 +6,7 @@
</label>
<!-- Current values display -->
<div
class="flex items-center justify-between mb-3 text-sm text-gray-600 dark:text-gray-400"
>
<div class="flex items-center justify-between mb-3 text-sm text-gray-600 dark:text-gray-400">
<span>{{ unit ? `${currentMin} ${unit}` : currentMin }}</span>
<span class="text-gray-400">to</span>
<span>{{ unit ? `${currentMax} ${unit}` : currentMax }}</span>
@@ -105,7 +103,7 @@
<div v-if="showInputs" class="flex items-center gap-3 mt-4">
<div class="flex-1">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Min {{ unit || "" }}
Min {{ unit || '' }}
</label>
<input
type="number"
@@ -122,7 +120,7 @@
<div class="flex-1">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Max {{ unit || "" }}
Max {{ unit || '' }}
</label>
<input
type="number"
@@ -150,27 +148,27 @@
<!-- Step size indicator -->
<div v-if="showStepInfo" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Step: {{ step }}{{ unit ? ` ${unit}` : "" }}
Step: {{ step }}{{ unit ? ` ${unit}` : '' }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface Props {
label: string;
min: number;
max: number;
value?: [number, number];
step?: number;
unit?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
showInputs?: boolean;
showTooltips?: boolean;
showStepInfo?: boolean;
label: string
min: number
max: number
value?: [number, number]
step?: number
unit?: string
required?: boolean
disabled?: boolean
clearable?: boolean
showInputs?: boolean
showTooltips?: boolean
showStepInfo?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -181,167 +179,157 @@ const props = withDefaults(defineProps<Props>(), {
showInputs: false,
showTooltips: true,
showStepInfo: false,
});
})
const emit = defineEmits<{
update: [value: [number, number] | undefined];
}>();
update: [value: [number, number] | undefined]
}>()
// Local state
const currentMin = ref(props.value?.[0] ?? props.min);
const currentMax = ref(props.value?.[1] ?? props.max);
const isDragging = ref(false);
const dragType = ref<"min" | "max" | null>(null);
const currentMin = ref(props.value?.[0] ?? props.min)
const currentMax = ref(props.value?.[1] ?? props.max)
const isDragging = ref(false)
const dragType = ref<'min' | 'max' | null>(null)
// Computed
const hasChanges = computed(() => {
return currentMin.value !== props.min || currentMax.value !== props.max;
});
return currentMin.value !== props.min || currentMax.value !== props.max
})
const minThumbPosition = computed(() => {
return ((currentMin.value - props.min) / (props.max - props.min)) * 100;
});
return ((currentMin.value - props.min) / (props.max - props.min)) * 100
})
const maxThumbPosition = computed(() => {
return ((currentMax.value - props.min) / (props.max - props.min)) * 100;
});
return ((currentMax.value - props.min) / (props.max - props.min)) * 100
})
const minThumbStyle = computed(() => ({
left: `calc(${minThumbPosition.value}% - 10px)`,
}));
}))
const maxThumbStyle = computed(() => ({
left: `calc(${maxThumbPosition.value}% - 10px)`,
}));
}))
const activeTrackStyle = computed(() => ({
left: `${minThumbPosition.value}%`,
width: `${maxThumbPosition.value - minThumbPosition.value}%`,
}));
}))
// Methods
const handleMinChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Math.min(Number(target.value), currentMax.value - props.step);
currentMin.value = value;
const target = event.target as HTMLInputElement
const value = Math.min(Number(target.value), currentMax.value - props.step)
currentMin.value = value
// Ensure min doesn't exceed max
if (currentMin.value >= currentMax.value) {
currentMin.value = currentMax.value - props.step;
currentMin.value = currentMax.value - props.step
}
};
}
const handleMaxChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Math.max(Number(target.value), currentMin.value + props.step);
currentMax.value = value;
const target = event.target as HTMLInputElement
const value = Math.max(Number(target.value), currentMin.value + props.step)
currentMax.value = value
// Ensure max doesn't go below min
if (currentMax.value <= currentMin.value) {
currentMax.value = currentMin.value + props.step;
currentMax.value = currentMin.value + props.step
}
};
}
const handleMinInputChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
const target = event.target as HTMLInputElement
const value = Number(target.value)
if (value >= props.min && value < currentMax.value) {
currentMin.value = value;
currentMin.value = value
}
};
}
const handleMaxInputChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
const target = event.target as HTMLInputElement
const value = Number(target.value)
if (value <= props.max && value > currentMin.value) {
currentMax.value = value;
currentMax.value = value
}
};
}
const emitChange = () => {
const hasDefaultValues =
currentMin.value === props.min && currentMax.value === props.max;
emit("update", hasDefaultValues ? undefined : [currentMin.value, currentMax.value]);
};
const hasDefaultValues = currentMin.value === props.min && currentMax.value === props.max
emit('update', hasDefaultValues ? undefined : [currentMin.value, currentMax.value])
}
const reset = () => {
currentMin.value = props.min;
currentMax.value = props.max;
emitChange();
};
currentMin.value = props.min
currentMax.value = props.max
emitChange()
}
const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
if (props.disabled) return;
const startDrag = (type: 'min' | 'max', event: MouseEvent | TouchEvent) => {
if (props.disabled) return
isDragging.value = true;
dragType.value = type;
isDragging.value = true
dragType.value = type
event.preventDefault();
event.preventDefault()
if (event instanceof MouseEvent) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", endDrag);
document.addEventListener('mousemove', handleDrag)
document.addEventListener('mouseup', endDrag)
} else {
document.addEventListener("touchmove", handleDrag);
document.addEventListener("touchend", endDrag);
document.addEventListener('touchmove', handleDrag)
document.addEventListener('touchend', endDrag)
}
};
}
const handleDrag = (event: MouseEvent | TouchEvent) => {
if (!isDragging.value || !dragType.value) return;
if (!isDragging.value || !dragType.value) return
const container = (event.target as Element).closest(".range-slider-container");
if (!container) return;
const container = (event.target as Element).closest('.range-slider-container')
if (!container) return
const rect = container.getBoundingClientRect();
const clientX = event instanceof MouseEvent ? event.clientX : event.touches[0].clientX;
const percentage = Math.max(
0,
Math.min(100, ((clientX - rect.left) / rect.width) * 100)
);
const value = props.min + (percentage / 100) * (props.max - props.min);
const steppedValue = Math.round(value / props.step) * props.step;
const rect = container.getBoundingClientRect()
const clientX = event instanceof MouseEvent ? event.clientX : event.touches[0].clientX
const percentage = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100))
const value = props.min + (percentage / 100) * (props.max - props.min)
const steppedValue = Math.round(value / props.step) * props.step
if (dragType.value === "min") {
currentMin.value = Math.max(
props.min,
Math.min(steppedValue, currentMax.value - props.step)
);
if (dragType.value === 'min') {
currentMin.value = Math.max(props.min, Math.min(steppedValue, currentMax.value - props.step))
} else {
currentMax.value = Math.min(
props.max,
Math.max(steppedValue, currentMin.value + props.step)
);
currentMax.value = Math.min(props.max, Math.max(steppedValue, currentMin.value + props.step))
}
};
}
const endDrag = () => {
isDragging.value = false;
dragType.value = null;
emitChange();
isDragging.value = false
dragType.value = null
emitChange()
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", handleDrag);
document.removeEventListener("touchend", endDrag);
};
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', endDrag)
document.removeEventListener('touchmove', handleDrag)
document.removeEventListener('touchend', endDrag)
}
// Watch for prop changes
onMounted(() => {
if (props.value) {
currentMin.value = props.value[0];
currentMax.value = props.value[1];
currentMin.value = props.value[0]
currentMax.value = props.value[1]
}
});
})
onUnmounted(() => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", handleDrag);
document.removeEventListener("touchend", endDrag);
});
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', endDrag)
document.removeEventListener('touchmove', handleDrag)
document.removeEventListener('touchend', endDrag)
})
</script>
<style scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -4,23 +4,20 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click="$emit('close')"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4"
@click.stop
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4" @click.stop>
<!-- Header -->
<div
class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ editMode ? "Edit Preset" : "Save Filter Preset" }}
{{ editMode ? 'Edit Preset' : 'Save Filter Preset' }}
</h3>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close dialog"
>
<Icon name="x" class="w-5 h-5" />
<i class="pi pi-times w-5 h-5" />
</button>
</div>
@@ -112,22 +109,15 @@
type="checkbox"
class="mr-2 text-blue-600 rounded"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
Set as my default preset
</span>
<span class="text-sm text-gray-700 dark:text-gray-300"> Set as my default preset </span>
</label>
</div>
<!-- Filter summary -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filters to Save
</h4>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Filters to Save</h4>
<div class="space-y-1">
<p
v-if="filterSummary.length === 0"
class="text-sm text-gray-500 dark:text-gray-400"
>
<p v-if="filterSummary.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
No active filters
</p>
<p
@@ -154,8 +144,8 @@
:disabled="!isValid || isLoading"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2"
>
<Icon v-if="isLoading" name="loading" class="w-4 h-4 animate-spin" />
{{ editMode ? "Update" : "Save" }} Preset
<i v-if="isLoading" class="pi pi-spinner pi-spin w-4 h-4" />
{{ editMode ? 'Update' : 'Save' }} Preset
</button>
</div>
</form>
@@ -164,168 +154,167 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import Icon from "@/components/ui/Icon.vue";
import type { FilterState, FilterPreset } from "@/types/filters";
import { ref, computed, watch, nextTick } from 'vue'
import type { FilterState, FilterPreset } from '@/types/filters'
interface Props {
isOpen: boolean;
filters: FilterState;
editMode?: boolean;
existingPreset?: FilterPreset;
allowGlobal?: boolean;
existingNames?: string[];
isOpen: boolean
filters: FilterState
editMode?: boolean
existingPreset?: FilterPreset
allowGlobal?: boolean
existingNames?: string[]
}
const props = withDefaults(defineProps<Props>(), {
editMode: false,
allowGlobal: false,
existingNames: () => [],
});
})
defineEmits<{
close: [];
save: [preset: Partial<FilterPreset>];
}>();
close: []
save: [preset: Partial<FilterPreset>]
}>()
// Form data
const formData = ref({
name: "",
description: "",
scope: "personal" as "personal" | "global",
name: '',
description: '',
scope: 'personal' as 'personal' | 'global',
isDefault: false,
});
})
// Form state
const errors = ref({
name: "",
});
const isLoading = ref(false);
name: '',
})
const isLoading = ref(false)
// Computed
const isValid = computed(() => {
return formData.value.name.trim().length > 0 && !errors.value.name;
});
return formData.value.name.trim().length > 0 && !errors.value.name
})
const filterSummary = computed(() => {
const summary: Array<{ key: string; label: string; value: string }> = [];
const filters = props.filters;
const summary: Array<{ key: string; label: string; value: string }> = []
const filters = props.filters
if (filters.search?.trim()) {
summary.push({
key: "search",
label: "Search",
key: 'search',
label: 'Search',
value: filters.search,
});
})
}
if (filters.categories?.length) {
summary.push({
key: "categories",
label: "Categories",
value: filters.categories.join(", "),
});
key: 'categories',
label: 'Categories',
value: filters.categories.join(', '),
})
}
if (filters.manufacturers?.length) {
summary.push({
key: "manufacturers",
label: "Manufacturers",
value: filters.manufacturers.join(", "),
});
key: 'manufacturers',
label: 'Manufacturers',
value: filters.manufacturers.join(', '),
})
}
if (filters.designers?.length) {
summary.push({
key: "designers",
label: "Designers",
value: filters.designers.join(", "),
});
key: 'designers',
label: 'Designers',
value: filters.designers.join(', '),
})
}
if (filters.parks?.length) {
summary.push({
key: "parks",
label: "Parks",
value: filters.parks.join(", "),
});
key: 'parks',
label: 'Parks',
value: filters.parks.join(', '),
})
}
if (filters.status?.length) {
summary.push({
key: "status",
label: "Status",
value: filters.status.join(", "),
});
key: 'status',
label: 'Status',
value: filters.status.join(', '),
})
}
if (filters.opened?.start || filters.opened?.end) {
const start = filters.opened.start || "Any";
const end = filters.opened.end || "Any";
const start = filters.opened.start || 'Any'
const end = filters.opened.end || 'Any'
summary.push({
key: "opened",
label: "Opened",
key: 'opened',
label: 'Opened',
value: `${start} to ${end}`,
});
})
}
if (filters.closed?.start || filters.closed?.end) {
const start = filters.closed.start || "Any";
const end = filters.closed.end || "Any";
const start = filters.closed.start || 'Any'
const end = filters.closed.end || 'Any'
summary.push({
key: "closed",
label: "Closed",
key: 'closed',
label: 'Closed',
value: `${start} to ${end}`,
});
})
}
// Range filters
const ranges = [
{ key: "heightRange", label: "Height", data: filters.heightRange, unit: "m" },
{ key: "speedRange", label: "Speed", data: filters.speedRange, unit: "km/h" },
{ key: "durationRange", label: "Duration", data: filters.durationRange, unit: "min" },
{ key: "capacityRange", label: "Capacity", data: filters.capacityRange, unit: "" },
];
{ key: 'heightRange', label: 'Height', data: filters.heightRange, unit: 'm' },
{ key: 'speedRange', label: 'Speed', data: filters.speedRange, unit: 'km/h' },
{ key: 'durationRange', label: 'Duration', data: filters.durationRange, unit: 'min' },
{ key: 'capacityRange', label: 'Capacity', data: filters.capacityRange, unit: '' },
]
ranges.forEach(({ key, label, data, unit }) => {
if (data?.min !== undefined || data?.max !== undefined) {
const min = data.min ?? "Any";
const max = data.max ?? "Any";
const min = data.min ?? 'Any'
const max = data.max ?? 'Any'
summary.push({
key,
label,
value: `${min} - ${max}${unit ? " " + unit : ""}`,
});
value: `${min} - ${max}${unit ? ' ' + unit : ''}`,
})
}
});
})
return summary;
});
return summary
})
// Methods
const validateName = () => {
const name = formData.value.name.trim();
errors.value.name = "";
const name = formData.value.name.trim()
errors.value.name = ''
if (name.length === 0) {
errors.value.name = "Preset name is required";
errors.value.name = 'Preset name is required'
} else if (name.length < 2) {
errors.value.name = "Preset name must be at least 2 characters";
errors.value.name = 'Preset name must be at least 2 characters'
} else if (name.length > 50) {
errors.value.name = "Preset name must be 50 characters or less";
errors.value.name = 'Preset name must be 50 characters or less'
} else if (
props.existingNames.includes(name.toLowerCase()) &&
(!props.editMode || name.toLowerCase() !== props.existingPreset?.name.toLowerCase())
) {
errors.value.name = "A preset with this name already exists";
errors.value.name = 'A preset with this name already exists'
}
};
}
const handleSave = async () => {
validateName();
if (!isValid.value) return;
validateName()
if (!isValid.value) return
isLoading.value = true;
isLoading.value = true
try {
const preset: Partial<FilterPreset> = {
name: formData.value.name.trim(),
@@ -334,24 +323,24 @@ const handleSave = async () => {
scope: formData.value.scope,
isDefault: formData.value.isDefault,
lastUsed: new Date().toISOString(),
};
}
if (props.editMode && props.existingPreset) {
preset.id = props.existingPreset.id;
preset.id = props.existingPreset.id
}
// Emit save event
await new Promise((resolve) => {
const emit = defineEmits<{
save: [preset: Partial<FilterPreset>];
}>();
emit("save", preset);
setTimeout(resolve, 100); // Small delay to simulate async operation
});
save: [preset: Partial<FilterPreset>]
}>()
emit('save', preset)
setTimeout(resolve, 100) // Small delay to simulate async operation
})
} finally {
isLoading.value = false;
isLoading.value = false
}
};
}
// Watchers
watch(
@@ -361,39 +350,39 @@ watch(
if (props.editMode && props.existingPreset) {
formData.value = {
name: props.existingPreset.name,
description: props.existingPreset.description || "",
scope: props.existingPreset.scope || "personal",
description: props.existingPreset.description || '',
scope: props.existingPreset.scope || 'personal',
isDefault: props.existingPreset.isDefault || false,
};
}
} else {
formData.value = {
name: "",
description: "",
scope: "personal",
name: '',
description: '',
scope: 'personal',
isDefault: false,
};
}
}
errors.value.name = "";
errors.value.name = ''
// Focus the name input
await nextTick();
const nameInput = document.getElementById("preset-name");
await nextTick()
const nameInput = document.getElementById('preset-name')
if (nameInput) {
nameInput.focus();
nameInput.focus()
}
}
},
{ immediate: true }
);
{ immediate: true },
)
watch(
() => formData.value.name,
() => {
if (errors.value.name) {
validateName();
validateName()
}
}
);
},
)
</script>
<style scoped>

View File

@@ -2,7 +2,7 @@
<div class="search-filter">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="search" class="w-4 h-4 text-gray-400" />
<i class="pi pi-search w-4 h-4 text-gray-400" />
</div>
<input
@@ -28,7 +28,7 @@
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Clear search"
>
<Icon name="x" class="w-4 h-4" />
<i class="pi pi-times w-4 h-4" />
</button>
</div>
</div>
@@ -59,9 +59,8 @@
role="option"
:aria-selected="highlightedIndex === index"
>
<Icon
:name="getSuggestionIcon(suggestion.type)"
class="w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"
<i
:class="`pi ${getSuggestionIcon(suggestion.type)} w-4 h-4 mr-3 text-gray-500 dark:text-gray-400`"
/>
<div class="flex-1 min-w-0">
@@ -87,9 +86,7 @@
<!-- Quick Search Filters -->
<div v-if="quickFilters.length > 0" class="mt-3">
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick Filters
</div>
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Quick Filters</div>
<div class="flex flex-wrap gap-2">
<button
@@ -98,7 +95,7 @@
@click="applyQuickFilter(filter)"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<Icon :name="filter.icon" class="w-3 h-3 mr-1" />
<i :class="`pi ${getIconClass(filter.icon)} w-3 h-3 mr-1`" />
{{ filter.label }}
</button>
</div>
@@ -107,188 +104,193 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { useRideFiltering } from "@/composables/useRideFiltering";
import { storeToRefs } from "pinia";
import { debounce } from "lodash-es";
import Icon from "@/components/ui/Icon.vue";
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRideFilteringStore } from '@/stores/rideFiltering'
import { useRideFiltering } from '@/composables/useRideFiltering'
import { storeToRefs } from 'pinia'
import { debounce } from 'lodash-es'
// Store
const store = useRideFilteringStore();
const {
searchQuery,
searchSuggestions,
showSuggestions: showStoreSuggestions,
} = storeToRefs(store);
const { setSearchQuery, showSearchSuggestions, hideSearchSuggestions } = store;
const store = useRideFilteringStore()
const { searchQuery, searchSuggestions, showSuggestions: showStoreSuggestions } = storeToRefs(store)
const { setSearchQuery, showSearchSuggestions, hideSearchSuggestions } = store
// Composable
const { getSearchSuggestions } = useRideFiltering();
const { getSearchSuggestions } = useRideFiltering()
// Local state
const searchInput = ref<HTMLInputElement>();
const highlightedIndex = ref(-1);
const showSuggestions = ref(false);
const isLoadingSuggestions = ref(false);
const searchInput = ref<HTMLInputElement>()
const highlightedIndex = ref(-1)
const showSuggestions = ref(false)
const isLoadingSuggestions = ref(false)
// Computed
const suggestions = computed(() => searchSuggestions.value || []);
const suggestions = computed(() => searchSuggestions.value || [])
const quickFilters = computed(() => [
{
value: "operating",
label: "Operating",
icon: "play",
filter: { status: ["operating"] },
value: 'operating',
label: 'Operating',
icon: 'play',
filter: { status: ['operating'] },
},
{
value: "roller_coaster",
label: "Roller Coasters",
icon: "trending-up",
filter: { category: ["roller_coaster"] },
value: 'roller_coaster',
label: 'Roller Coasters',
icon: 'trending-up',
filter: { category: ['roller_coaster'] },
},
{
value: "water_ride",
label: "Water Rides",
icon: "droplet",
filter: { category: ["water_ride"] },
value: 'water_ride',
label: 'Water Rides',
icon: 'droplet',
filter: { category: ['water_ride'] },
},
{
value: "family",
label: "Family Friendly",
icon: "users",
filter: { category: ["family"] },
value: 'family',
label: 'Family Friendly',
icon: 'users',
filter: { category: ['family'] },
},
]);
])
// Methods
const handleSearchInput = debounce(async (event: Event) => {
const target = event.target as HTMLInputElement;
const query = target.value;
const target = event.target as HTMLInputElement
const query = target.value
setSearchQuery(query);
setSearchQuery(query)
if (query.length >= 2) {
isLoadingSuggestions.value = true;
showSuggestions.value = true;
isLoadingSuggestions.value = true
showSuggestions.value = true
try {
await getSearchSuggestions(query);
await getSearchSuggestions(query)
} catch (error) {
console.error("Failed to load search suggestions:", error);
console.error('Failed to load search suggestions:', error)
} finally {
isLoadingSuggestions.value = false;
isLoadingSuggestions.value = false
}
} else {
showSuggestions.value = false;
highlightedIndex.value = -1;
showSuggestions.value = false
highlightedIndex.value = -1
}
}, 300);
}, 300)
const handleBlur = () => {
// Delay hiding suggestions to allow for clicks
setTimeout(() => {
showSuggestions.value = false;
highlightedIndex.value = -1;
}, 150);
};
showSuggestions.value = false
highlightedIndex.value = -1
}, 150)
}
const handleKeydown = async (event: KeyboardEvent) => {
if (!showSuggestions.value || suggestions.value.length === 0) return;
if (!showSuggestions.value || suggestions.value.length === 0) return
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
suggestions.value.length - 1
);
break;
case 'ArrowDown':
event.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, suggestions.value.length - 1)
break
case "ArrowUp":
event.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
break;
case 'ArrowUp':
event.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
break
case "Enter":
event.preventDefault();
case 'Enter':
event.preventDefault()
if (highlightedIndex.value >= 0) {
selectSuggestion(suggestions.value[highlightedIndex.value]);
selectSuggestion(suggestions.value[highlightedIndex.value])
}
break;
break
case "Escape":
showSuggestions.value = false;
highlightedIndex.value = -1;
searchInput.value?.blur();
break;
case 'Escape':
showSuggestions.value = false
highlightedIndex.value = -1
searchInput.value?.blur()
break
}
};
}
const selectSuggestion = (suggestion: any) => {
setSearchQuery(suggestion.label);
showSuggestions.value = false;
highlightedIndex.value = -1;
setSearchQuery(suggestion.label)
showSuggestions.value = false
highlightedIndex.value = -1
// Apply additional filters based on suggestion type
if (suggestion.filters) {
Object.entries(suggestion.filters).forEach(([key, value]) => {
store.updateFilter(key as any, value);
});
store.updateFilter(key as any, value)
})
}
};
}
const clearSearch = () => {
setSearchQuery("");
showSuggestions.value = false;
highlightedIndex.value = -1;
searchInput.value?.focus();
};
setSearchQuery('')
showSuggestions.value = false
highlightedIndex.value = -1
searchInput.value?.focus()
}
const applyQuickFilter = (filter: any) => {
Object.entries(filter.filter).forEach(([key, value]) => {
store.updateFilter(key as any, value);
});
};
store.updateFilter(key as any, value)
})
}
const getSuggestionIcon = (type: string): string => {
const icons: Record<string, string> = {
ride: "activity",
park: "map-pin",
manufacturer: "building",
designer: "user",
category: "tag",
location: "globe",
};
return icons[type] || "search";
};
ride: 'pi-chart-line',
park: 'pi-map-marker',
manufacturer: 'pi-building',
designer: 'pi-user',
category: 'pi-tag',
location: 'pi-globe',
}
return icons[type] || 'pi-search'
}
const getIconClass = (iconName: string): string => {
const iconMap: Record<string, string> = {
'play': 'pi-play',
'trending-up': 'pi-chart-line',
'droplet': 'pi-tint',
'users': 'pi-users',
'search': 'pi-search',
'filter': 'pi-filter',
'x': 'pi-times',
}
return iconMap[iconName] || 'pi-circle'
}
// Lifecycle
onMounted(() => {
// Focus search input on mount if no active filters
if (!store.hasActiveFilters) {
nextTick(() => {
searchInput.value?.focus();
});
searchInput.value?.focus()
})
}
});
})
// Handle clicks outside
const handleClickOutside = (event: Event) => {
if (searchInput.value && !searchInput.value.contains(event.target as Node)) {
showSuggestions.value = false;
highlightedIndex.value = -1;
showSuggestions.value = false
highlightedIndex.value = -1
}
};
}
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>

View File

@@ -1,9 +1,6 @@
<template>
<div class="searchable-select">
<label
:for="id"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
<label :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
@@ -12,7 +9,7 @@
<!-- Search input -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="search" class="w-4 h-4 text-gray-400" />
<i class="pi pi-search w-4 h-4 text-gray-400" />
</div>
<input
@@ -45,12 +42,11 @@
class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
:disabled="disabled"
>
<Icon name="x" class="w-4 h-4" />
<i class="pi pi-times w-4 h-4" />
</button>
<Icon
<i
v-else
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ml-1"
class="pi pi-chevron-down w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ml-1"
:class="{ 'rotate-180': isOpen }"
/>
</div>
@@ -69,7 +65,7 @@
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100"
:disabled="disabled"
>
<Icon name="x" class="w-3 h-3" />
<i class="pi pi-times w-3 h-3" />
</button>
</span>
</div>
@@ -89,9 +85,7 @@
>
<!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center py-4">
<div
class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"
></div>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span>
</div>
@@ -153,10 +147,7 @@
</div>
<!-- No options message -->
<div
v-else
class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"
>
<div v-else class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ noOptionsMessage }}
</div>
</div>
@@ -179,256 +170,255 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import { debounce } from "lodash-es";
import Icon from "@/components/ui/Icon.vue";
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { debounce } from 'lodash-es'
interface Option {
value: string | number;
label: string;
description?: string;
count?: number;
disabled?: boolean;
value: string | number
label: string
description?: string
count?: number
disabled?: boolean
}
interface Props {
id: string;
label: string;
value?: (string | number)[];
options: Option[];
searchPlaceholder?: string;
noOptionsMessage?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
allowCreate?: boolean;
isLoading?: boolean;
minSearchLength?: number;
id: string
label: string
value?: (string | number)[]
options: Option[]
searchPlaceholder?: string
noOptionsMessage?: string
required?: boolean
disabled?: boolean
clearable?: boolean
allowCreate?: boolean
isLoading?: boolean
minSearchLength?: number
}
const props = withDefaults(defineProps<Props>(), {
searchPlaceholder: "Search options...",
noOptionsMessage: "No options available",
searchPlaceholder: 'Search options...',
noOptionsMessage: 'No options available',
required: false,
disabled: false,
clearable: true,
allowCreate: false,
isLoading: false,
minSearchLength: 0,
});
})
const emit = defineEmits<{
update: [value: (string | number)[] | undefined];
search: [query: string];
create: [value: string];
}>();
update: [value: (string | number)[] | undefined]
search: [query: string]
create: [value: string]
}>()
// Local state
const searchInput = ref<HTMLInputElement>();
const searchQuery = ref("");
const isOpen = ref(false);
const highlightedIndex = ref(-1);
const searchInput = ref<HTMLInputElement>()
const searchQuery = ref('')
const isOpen = ref(false)
const highlightedIndex = ref(-1)
// Computed
const selectedValues = computed(() => {
return Array.isArray(props.value) ? props.value : [];
});
return Array.isArray(props.value) ? props.value : []
})
const selectedOptions = computed(() => {
return props.options.filter((option) => selectedValues.value.includes(option.value));
});
return props.options.filter((option) => selectedValues.value.includes(option.value))
})
const hasSelection = computed(() => {
return selectedValues.value.length > 0;
});
return selectedValues.value.length > 0
})
const selectedCount = computed(() => {
return selectedValues.value.length;
});
return selectedValues.value.length
})
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.length < props.minSearchLength) {
return props.options;
return props.options
}
const query = searchQuery.value.toLowerCase();
const query = searchQuery.value.toLowerCase()
return props.options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
(option.description && option.description.toLowerCase().includes(query))
);
});
(option.description && option.description.toLowerCase().includes(query)),
)
})
// Methods
const handleSearchInput = debounce((event: Event) => {
const target = event.target as HTMLInputElement;
const query = target.value;
searchQuery.value = query;
const target = event.target as HTMLInputElement
const query = target.value
searchQuery.value = query
if (query.length >= props.minSearchLength) {
emit("search", query);
emit('search', query)
}
if (!isOpen.value && query) {
isOpen.value = true;
isOpen.value = true
}
}, 300);
}, 300)
const handleFocus = () => {
if (!props.disabled) {
isOpen.value = true;
highlightedIndex.value = -1;
isOpen.value = true
highlightedIndex.value = -1
}
};
}
const handleBlur = () => {
// Delay hiding to allow for clicks
setTimeout(() => {
if (!searchInput.value?.matches(":focus")) {
isOpen.value = false;
highlightedIndex.value = -1;
if (!searchInput.value?.matches(':focus')) {
isOpen.value = false
highlightedIndex.value = -1
}
}, 150);
};
}, 150)
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
if (event.key === "ArrowDown" || event.key === "Enter") {
event.preventDefault();
isOpen.value = true;
return;
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault()
isOpen.value = true
return
}
return;
return
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
case 'ArrowDown':
event.preventDefault()
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredOptions.value.length - 1
);
break;
filteredOptions.value.length - 1,
)
break
case "ArrowUp":
event.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
break;
case 'ArrowUp':
event.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
break
case "Enter":
event.preventDefault();
case 'Enter':
event.preventDefault()
if (highlightedIndex.value >= 0 && filteredOptions.value[highlightedIndex.value]) {
toggleOption(filteredOptions.value[highlightedIndex.value].value);
toggleOption(filteredOptions.value[highlightedIndex.value].value)
} else if (props.allowCreate && searchQuery.value) {
createOption();
createOption()
}
break;
break
case "Escape":
isOpen.value = false;
highlightedIndex.value = -1;
searchInput.value?.blur();
break;
case 'Escape':
isOpen.value = false
highlightedIndex.value = -1
searchInput.value?.blur()
break
case "Backspace":
case 'Backspace':
if (!searchQuery.value && hasSelection.value) {
// Remove last selected item when backspacing with empty search
const lastSelected = selectedValues.value[selectedValues.value.length - 1];
removeOption(lastSelected);
const lastSelected = selectedValues.value[selectedValues.value.length - 1]
removeOption(lastSelected)
}
break;
break
}
};
}
const toggleOption = (optionValue: string | number) => {
if (props.disabled) return;
if (props.disabled) return
const currentValues = [...selectedValues.value];
const index = currentValues.indexOf(optionValue);
const currentValues = [...selectedValues.value]
const index = currentValues.indexOf(optionValue)
if (index >= 0) {
currentValues.splice(index, 1);
currentValues.splice(index, 1)
} else {
currentValues.push(optionValue);
currentValues.push(optionValue)
}
emit("update", currentValues.length > 0 ? currentValues : undefined);
emit('update', currentValues.length > 0 ? currentValues : undefined)
// Clear search after selection
searchQuery.value = "";
searchQuery.value = ''
nextTick(() => {
searchInput.value?.focus();
});
};
searchInput.value?.focus()
})
}
const removeOption = (optionValue: string | number) => {
if (props.disabled) return;
if (props.disabled) return
const currentValues = [...selectedValues.value];
const index = currentValues.indexOf(optionValue);
const currentValues = [...selectedValues.value]
const index = currentValues.indexOf(optionValue)
if (index >= 0) {
currentValues.splice(index, 1);
emit("update", currentValues.length > 0 ? currentValues : undefined);
currentValues.splice(index, 1)
emit('update', currentValues.length > 0 ? currentValues : undefined)
}
};
}
const isSelected = (optionValue: string | number): boolean => {
return selectedValues.value.includes(optionValue);
};
return selectedValues.value.includes(optionValue)
}
const clearSelection = () => {
if (!props.disabled) {
emit("update", undefined);
emit('update', undefined)
}
};
}
const clearAll = () => {
searchQuery.value = "";
searchQuery.value = ''
if (hasSelection.value) {
clearSelection();
clearSelection()
}
searchInput.value?.focus();
};
searchInput.value?.focus()
}
const createOption = () => {
if (props.allowCreate && searchQuery.value.trim()) {
emit("create", searchQuery.value.trim());
searchQuery.value = "";
emit('create', searchQuery.value.trim())
searchQuery.value = ''
}
};
}
const highlightSearchTerm = (text: string): string => {
if (!searchQuery.value) return text;
if (!searchQuery.value) return text
const regex = new RegExp(`(${searchQuery.value})`, "gi");
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>');
};
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>')
}
// Handle clicks outside
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest(".searchable-select")) {
isOpen.value = false;
highlightedIndex.value = -1;
const target = event.target as HTMLElement
if (!target.closest('.searchable-select')) {
isOpen.value = false
highlightedIndex.value = -1
}
};
}
// Watch for options changes to reset highlighted index
watch(
() => filteredOptions.value.length,
() => {
highlightedIndex.value = -1;
}
);
highlightedIndex.value = -1
},
)
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>

View File

@@ -1,9 +1,6 @@
<template>
<div class="select-filter">
<label
:for="id"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
<label :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
@@ -63,15 +60,14 @@
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100"
:disabled="disabled"
>
<Icon name="x" class="w-3 h-3" />
<i class="pi pi-times w-3 h-3" />
</button>
</span>
</span>
</span>
<Icon
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
<i
class="pi pi-chevron-down w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</button>
@@ -128,10 +124,7 @@
</div>
<!-- Selected count indicator for multiple -->
<div
v-if="multiple && hasSelection"
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
>
<div v-if="multiple && hasSelection" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ selectedCount }} selected
</div>
@@ -142,32 +135,31 @@
class="mt-2 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Clear {{ multiple ? "all" : "selection" }}
Clear {{ multiple ? 'all' : 'selection' }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import Icon from "@/components/ui/Icon.vue";
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface Option {
value: string | number;
label: string;
count?: number;
disabled?: boolean;
value: string | number
label: string
count?: number
disabled?: boolean
}
interface Props {
id: string;
label: string;
value?: string | number | (string | number)[];
options: (Option | string | number)[];
multiple?: boolean;
placeholder?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
id: string
label: string
value?: string | number | (string | number)[]
options: (Option | string | number)[]
multiple?: boolean
placeholder?: string
required?: boolean
disabled?: boolean
clearable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -175,125 +167,123 @@ const props = withDefaults(defineProps<Props>(), {
required: false,
disabled: false,
clearable: true,
});
})
const emit = defineEmits<{
update: [value: string | number | (string | number)[] | undefined];
}>();
update: [value: string | number | (string | number)[] | undefined]
}>()
// Local state
const isOpen = ref(false);
const isOpen = ref(false)
// Computed
const normalizedOptions = computed((): Option[] => {
return props.options.map((option) => {
if (typeof option === "string" || typeof option === "number") {
if (typeof option === 'string' || typeof option === 'number') {
return {
value: option,
label: String(option),
};
}
}
return option;
});
});
return option
})
})
const selectedValues = computed(() => {
if (!props.multiple) {
return props.value ? [props.value] : [];
return props.value ? [props.value] : []
}
return Array.isArray(props.value) ? props.value : [];
});
return Array.isArray(props.value) ? props.value : []
})
const selectedOptions = computed(() => {
return normalizedOptions.value.filter((option) =>
selectedValues.value.includes(option.value)
);
});
return normalizedOptions.value.filter((option) => selectedValues.value.includes(option.value))
})
const hasSelection = computed(() => {
return selectedValues.value.length > 0;
});
return selectedValues.value.length > 0
})
const selectedCount = computed(() => {
return selectedValues.value.length;
});
return selectedValues.value.length
})
// Methods
const handleSingleChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
emit("update", value || undefined);
};
const target = event.target as HTMLSelectElement
const value = target.value
emit('update', value || undefined)
}
const toggleDropdown = () => {
if (!props.disabled) {
isOpen.value = !isOpen.value;
isOpen.value = !isOpen.value
}
};
}
const toggleOption = (optionValue: string | number) => {
if (props.disabled) return;
if (props.disabled) return
if (!props.multiple) {
emit("update", optionValue);
isOpen.value = false;
return;
emit('update', optionValue)
isOpen.value = false
return
}
const currentValues = Array.isArray(props.value) ? [...props.value] : [];
const index = currentValues.indexOf(optionValue);
const currentValues = Array.isArray(props.value) ? [...props.value] : []
const index = currentValues.indexOf(optionValue)
if (index >= 0) {
currentValues.splice(index, 1);
currentValues.splice(index, 1)
} else {
currentValues.push(optionValue);
currentValues.push(optionValue)
}
emit("update", currentValues.length > 0 ? currentValues : undefined);
};
emit('update', currentValues.length > 0 ? currentValues : undefined)
}
const removeOption = (optionValue: string | number) => {
if (props.disabled) return;
if (props.disabled) return
if (!props.multiple) {
emit("update", undefined);
return;
emit('update', undefined)
return
}
const currentValues = Array.isArray(props.value) ? [...props.value] : [];
const index = currentValues.indexOf(optionValue);
const currentValues = Array.isArray(props.value) ? [...props.value] : []
const index = currentValues.indexOf(optionValue)
if (index >= 0) {
currentValues.splice(index, 1);
emit("update", currentValues.length > 0 ? currentValues : undefined);
currentValues.splice(index, 1)
emit('update', currentValues.length > 0 ? currentValues : undefined)
}
};
}
const isSelected = (optionValue: string | number): boolean => {
return selectedValues.value.includes(optionValue);
};
return selectedValues.value.includes(optionValue)
}
const clearSelection = () => {
if (!props.disabled) {
emit("update", undefined);
emit('update', undefined)
}
};
}
// Handle clicks outside
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest(".multi-select-container")) {
isOpen.value = false;
const target = event.target as HTMLElement
if (!target.closest('.multi-select-container')) {
isOpen.value = false
}
};
}
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>

View File

@@ -0,0 +1,172 @@
<template>
<div :class="containerClasses">
<!-- Button variant -->
<PrimeButton
v-if="variant === 'button'"
:variant="'ghost'"
:size="size === 'sm' ? 'small' : size === 'lg' ? 'large' : undefined"
:icon-start="currentTheme === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
:icon-only="!showText"
@click="toggleTheme"
:aria-label="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
>
<span v-if="showText">
{{ currentTheme === 'dark' ? 'Light' : 'Dark' }}
</span>
</PrimeButton>
<!-- Dropdown variant -->
<div v-else-if="variant === 'dropdown'" class="relative">
<PrimeButton
:variant="'ghost'"
:size="size === 'sm' ? 'small' : size === 'lg' ? 'large' : undefined"
:icon-start="getThemeIcon()"
:icon-end="showDropdown ? 'pi pi-chevron-down' : undefined"
@click="dropdownOpen = !dropdownOpen"
:aria-expanded="dropdownOpen"
aria-haspopup="true"
>
<span v-if="showText">
{{ getThemeLabel() }}
</span>
</PrimeButton>
<!-- Dropdown menu -->
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="dropdownOpen"
:class="dropdownMenuClasses"
role="menu"
aria-orientation="vertical"
@click.stop
>
<!-- Light mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'light')"
@click="setTheme('light')"
role="menuitem"
>
<i class="pi pi-sun mr-2" />
Light
</button>
<!-- Dark mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'dark')"
@click="setTheme('dark')"
role="menuitem"
>
<i class="pi pi-moon mr-2" />
Dark
</button>
<!-- System mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'system')"
@click="setTheme('system')"
role="menuitem"
>
<i class="pi pi-desktop mr-2" />
System
</button>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { useTheme } from '@/composables/useTheme'
import PrimeButton from '@/components/primevue/PrimeButton.vue'
interface PrimeThemeControllerProps {
variant?: 'button' | 'dropdown'
size?: 'sm' | 'md' | 'lg'
showText?: boolean
showDropdown?: boolean
position?: 'fixed' | 'relative'
}
const props = withDefaults(defineProps<PrimeThemeControllerProps>(), {
variant: 'button',
size: 'md',
showText: false,
showDropdown: true,
position: 'relative',
})
// Use theme composable
const { currentTheme, setTheme, toggleTheme } = useTheme()
// Dropdown state
const dropdownOpen = ref(false)
const dropdownRef = ref()
// Close dropdown when clicking outside
onClickOutside(dropdownRef, () => {
dropdownOpen.value = false
})
// Computed classes
const containerClasses = computed(() => {
return props.position === 'fixed' ? 'fixed top-4 right-4 z-50' : 'relative'
})
const dropdownMenuClasses = computed(() => {
return [
'absolute right-0 mt-2 w-48 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5',
'dark:bg-gray-800 dark:ring-gray-700',
'focus:outline-none z-50 py-1',
].join(' ')
})
// Helper methods
const getThemeIcon = () => {
if (currentTheme.value === 'dark') return 'pi pi-moon'
if (currentTheme.value === 'light') return 'pi pi-sun'
return 'pi pi-desktop'
}
const getThemeLabel = () => {
if (currentTheme.value === 'dark') return 'Dark'
if (currentTheme.value === 'light') return 'Light'
return 'System'
}
// Dropdown item classes
const dropdownItemClasses = (isActive: boolean) => {
const baseClasses =
'flex w-full items-center px-4 py-2 text-left text-sm transition-colors rounded-md mx-1'
const activeClasses = isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
return `${baseClasses} ${activeClasses}`
}
</script>
<style scoped>
/* Additional styling for dropdown transitions */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<Badge :class="badgeClasses" :severity="severity" :size="size" :value="value">
<slot />
</Badge>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Badge from 'primevue/badge'
interface PrimeBadgeProps {
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'
size?: 'sm' | 'md' | 'lg'
value?: string | number
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
}
const props = withDefaults(defineProps<PrimeBadgeProps>(), {
variant: 'default',
size: 'md',
rounded: 'md',
})
// Map variants to PrimeVue severity
const severity = computed(() => {
const severityMap = {
default: 'info',
secondary: 'secondary',
destructive: 'danger',
outline: 'contrast',
success: 'success',
warning: 'warn',
info: 'info',
}
return severityMap[props.variant] as
| 'success'
| 'info'
| 'warn'
| 'danger'
| 'secondary'
| 'contrast'
| undefined
})
// Additional classes for styling
const badgeClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'text-xs px-2 py-1',
md: 'text-sm px-2.5 py-1',
lg: 'text-base px-3 py-1.5',
}
classes.push(sizeClasses[props.size])
// Border radius
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
}
classes.push(roundedClasses[props.rounded])
// Variant-specific styling
if (props.variant === 'outline') {
classes.push('border border-gray-300 dark:border-gray-600 bg-transparent')
}
return classes.join(' ')
})
</script>
<style scoped>
/* Custom badge styling to match our theme */
:deep(.p-badge) {
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Default variant with gradient */
:deep(.p-badge.p-badge-info) {
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white;
border: none;
}
/* Dark mode adjustments */
.dark :deep(.p-badge.p-badge-info) {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
}
/* Outline variant */
:deep(.p-badge.border) {
background: transparent !important;
color: #374151;
}
.dark :deep(.p-badge.border) {
color: #d1d5db;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<Button
:class="buttonClasses"
:disabled="disabled || loading"
:type="type"
:severity="severity"
:size="size"
:outlined="variant === 'outline'"
:text="variant === 'ghost' || variant === 'link'"
:link="variant === 'link'"
:loading="loading"
:loadingIcon="loadingIcon"
:icon="iconStart"
:iconPos="iconStart ? 'left' : iconEnd ? 'right' : undefined"
@click="handleClick"
>
<!-- Button content -->
<template v-if="!iconOnly">
<slot />
</template>
<!-- Icon for icon-only buttons -->
<template v-if="iconOnly && (iconStart || iconEnd)" #icon>
<i :class="iconStart || iconEnd" />
</template>
</Button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from 'primevue/button'
interface PrimeButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'small' | 'large' | undefined
disabled?: boolean
loading?: boolean
block?: boolean
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
iconStart?: string
iconEnd?: string
iconOnly?: boolean
href?: string
to?: string | object
target?: string
type?: 'button' | 'submit' | 'reset'
loadingIcon?: string
}
const props = withDefaults(defineProps<PrimeButtonProps>(), {
variant: 'primary',
disabled: false,
loading: false,
block: false,
rounded: 'md',
iconOnly: false,
type: 'button',
loadingIcon: 'pi pi-spin pi-spinner',
})
const emit = defineEmits<{
click: [event: Event]
}>()
// Map variants to PrimeVue severity
const severity = computed(() => {
const severityMap = {
primary: 'primary',
secondary: 'secondary',
outline: 'primary',
ghost: 'secondary',
link: 'secondary',
destructive: 'danger',
}
return severityMap[props.variant] as
| 'primary'
| 'secondary'
| 'success'
| 'info'
| 'warn'
| 'help'
| 'danger'
| 'contrast'
| undefined
})
// Additional classes for styling
const buttonClasses = computed(() => {
const classes = []
// Block width
if (props.block) {
classes.push('w-full')
}
// Border radius
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
}
classes.push(roundedClasses[props.rounded])
// Gradient for primary buttons
if (props.variant === 'primary' && !props.loading && !props.disabled) {
classes.push(
'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700',
)
classes.push('border-0 text-white')
}
// Icon-only styling
if (props.iconOnly) {
classes.push('aspect-square')
}
// Link variant styling
if (props.variant === 'link') {
classes.push('underline-offset-4 hover:underline')
}
return classes.join(' ')
})
// Handle click events
const handleClick = (event: Event) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style scoped>
/* Custom gradient override for primary buttons */
.p-button.bg-gradient-to-r {
background-image: linear-gradient(to right, #2563eb, #7c3aed) !important;
}
.p-button.bg-gradient-to-r:hover {
background-image: linear-gradient(to right, #1d4ed8, #6d28d9) !important;
}
.p-button.bg-gradient-to-r:active {
background-image: linear-gradient(to right, #1e40af, #5b21b6) !important;
}
/* Dark mode gradient adjustments */
.dark .p-button.bg-gradient-to-r {
background-image: linear-gradient(to right, #3b82f6, #8b5cf6) !important;
}
.dark .p-button.bg-gradient-to-r:hover {
background-image: linear-gradient(to right, #2563eb, #7c3aed) !important;
}
.dark .p-button.bg-gradient-to-r:active {
background-image: linear-gradient(to right, #1d4ed8, #6d28d9) !important;
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<Card :class="cardClasses">
<!-- Header slot -->
<template v-if="title || $slots.header" #header>
<div :class="headerClasses">
<slot name="header">
<h3 v-if="title" :class="titleClasses">
{{ title }}
</h3>
</slot>
</div>
</template>
<!-- Content slot -->
<template #content>
<div :class="contentClasses">
<slot />
</div>
</template>
<!-- Footer slot -->
<template v-if="$slots.footer" #footer>
<div :class="footerClasses">
<slot name="footer" />
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Card from 'primevue/card'
interface PrimeCardProps {
variant?: 'default' | 'outline' | 'ghost' | 'elevated' | 'featured'
size?: 'sm' | 'md' | 'lg' | 'xl'
title?: string
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
hover?: boolean
interactive?: boolean
bordered?: boolean
}
const props = withDefaults(defineProps<PrimeCardProps>(), {
variant: 'default',
size: 'md',
padding: 'md',
rounded: 'lg',
shadow: 'sm',
hover: false,
interactive: false,
bordered: true,
})
// Card classes
const cardClasses = computed(() => {
const classes = []
// Variant styling
if (props.variant === 'featured') {
classes.push('border-blue-200 dark:border-blue-800')
classes.push('bg-gradient-to-br from-blue-50 to-white dark:from-blue-950 dark:to-gray-800')
} else if (props.variant === 'outline') {
classes.push('border-2 border-gray-300 dark:border-gray-600')
} else if (props.variant === 'ghost') {
classes.push('border-0 bg-transparent dark:bg-transparent shadow-none')
} else if (props.variant === 'elevated') {
classes.push('border-0 shadow-lg')
}
// Shadow
if (props.variant !== 'ghost') {
const shadowClasses = {
none: '',
sm: 'shadow-sm hover:shadow-md',
md: 'shadow-md hover:shadow-lg',
lg: 'shadow-lg hover:shadow-xl',
xl: 'shadow-xl hover:shadow-2xl',
'2xl': 'shadow-2xl',
}
classes.push(shadowClasses[props.shadow])
}
// Rounded corners
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
}
classes.push(roundedClasses[props.rounded])
// Interactive effects
if (props.interactive) {
classes.push('cursor-pointer hover:scale-[1.01] active:scale-[0.99] hover:-translate-y-0.5')
classes.push('transition-all duration-200')
} else if (props.hover) {
classes.push('hover:shadow-lg transition-shadow duration-200')
}
// Block width
classes.push('w-full')
return classes.join(' ')
})
// Header classes
const headerClasses = computed(() => {
const classes = ['border-b border-gray-200 dark:border-gray-700']
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
}
classes.push(paddingClasses[props.padding])
return classes.join(' ')
})
// Content classes
const contentClasses = computed(() => {
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
}
return paddingClasses[props.padding]
})
// Footer classes
const footerClasses = computed(() => {
const classes = ['border-t border-gray-200 dark:border-gray-700']
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
}
classes.push(paddingClasses[props.padding])
return classes.join(' ')
})
// Title classes
const titleClasses = computed(() => {
const sizeClasses = {
sm: 'text-lg font-semibold',
md: 'text-xl font-semibold',
lg: 'text-2xl font-semibold',
xl: 'text-3xl font-bold',
}
return `${sizeClasses[props.size]} text-gray-900 dark:text-gray-100 tracking-tight`
})
</script>
<style scoped>
/* Override PrimeVue Card default styles to match our design */
:deep(.p-card) {
background: transparent;
border: none;
box-shadow: none;
}
:deep(.p-card-header) {
padding: 0;
border: none;
}
:deep(.p-card-content) {
padding: 0;
}
:deep(.p-card-footer) {
padding: 0;
border: none;
}
</style>

View File

@@ -0,0 +1,225 @@
<template>
<Dialog
:visible="visible"
:modal="modal"
:header="header"
:closable="closable"
:draggable="draggable"
:resizable="resizable"
:maximizable="maximizable"
:class="dialogClasses"
:style="dialogStyle"
@update:visible="handleVisibleChange"
@hide="handleHide"
@show="handleShow"
>
<!-- Header slot -->
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<!-- Content -->
<div :class="contentClasses">
<slot />
</div>
<!-- Footer slot -->
<template v-if="$slots.footer" #footer>
<div :class="footerClasses">
<slot name="footer" />
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Dialog from 'primevue/dialog'
interface PrimeDialogProps {
visible?: boolean
header?: string
modal?: boolean
closable?: boolean
draggable?: boolean
resizable?: boolean
maximizable?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
position?:
| 'center'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
}
const props = withDefaults(defineProps<PrimeDialogProps>(), {
visible: false,
modal: true,
closable: true,
draggable: false,
resizable: false,
maximizable: false,
size: 'md',
position: 'center',
})
const emit = defineEmits<{
'update:visible': [value: boolean]
hide: []
show: []
}>()
// Dialog classes
const dialogClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'w-full max-w-sm',
md: 'w-full max-w-md',
lg: 'w-full max-w-lg',
xl: 'w-full max-w-xl',
full: 'w-full max-w-full h-full',
}
classes.push(sizeClasses[props.size])
// Position classes
const positionClasses = {
center: '',
top: 'mt-8',
bottom: 'mb-8',
left: 'ml-8',
right: 'mr-8',
'top-left': 'mt-8 ml-8',
'top-right': 'mt-8 mr-8',
'bottom-left': 'mb-8 ml-8',
'bottom-right': 'mb-8 mr-8',
}
classes.push(positionClasses[props.position])
return classes.join(' ')
})
// Dialog style
const dialogStyle = computed(() => {
const styles: Record<string, string> = {}
if (props.size === 'full') {
styles.width = '100vw'
styles.height = '100vh'
styles.maxWidth = '100vw'
styles.maxHeight = '100vh'
}
return styles
})
// Content classes
const contentClasses = computed(() => {
const classes = ['text-gray-900 dark:text-gray-100']
// Add padding based on size
const paddingClasses = {
sm: 'p-4',
md: 'p-6',
lg: 'p-6',
xl: 'p-8',
full: 'p-8',
}
classes.push(paddingClasses[props.size])
return classes.join(' ')
})
// Footer classes
const footerClasses = computed(() => {
return 'flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700'
})
// Event handlers
const handleVisibleChange = (value: boolean) => {
emit('update:visible', value)
}
const handleHide = () => {
emit('hide')
}
const handleShow = () => {
emit('show')
}
</script>
<style scoped>
/* Override PrimeVue Dialog styles */
:deep(.p-dialog) {
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid #e5e7eb;
}
.dark :deep(.p-dialog) {
background-color: #1f2937;
border-color: #374151;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
:deep(.p-dialog-header) {
background: transparent;
border-bottom: 1px solid #e5e7eb;
border-radius: 0.75rem 0.75rem 0 0;
padding: 1.5rem 1.5rem 1rem 1.5rem;
}
.dark :deep(.p-dialog-header) {
border-bottom-color: #374151;
}
:deep(.p-dialog-title) {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.dark :deep(.p-dialog-title) {
color: #f9fafb;
}
:deep(.p-dialog-content) {
padding: 0;
background: transparent;
}
:deep(.p-dialog-footer) {
background: transparent;
border-top: none;
padding: 0 1.5rem 1.5rem 1.5rem;
}
:deep(.p-dialog-header-close) {
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
color: #6b7280;
transition: all 0.2s;
}
:deep(.p-dialog-header-close:hover) {
background-color: #f3f4f6;
color: #374151;
}
.dark :deep(.p-dialog-header-close) {
color: #9ca3af;
}
.dark :deep(.p-dialog-header-close:hover) {
background-color: #374151;
color: #d1d5db;
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div :class="wrapperClasses">
<!-- Label -->
<label v-if="label" :for="inputId" :class="labelClasses">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Input wrapper for icons -->
<div class="relative">
<!-- Start icon -->
<div
v-if="iconStart"
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
>
<i :class="[iconStart, 'text-gray-400 dark:text-gray-500']" />
</div>
<!-- Input field -->
<InputText
:id="inputId"
:class="inputClasses"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:invalid="invalid"
:modelValue="modelValue"
@update:modelValue="handleInput"
@blur="handleBlur"
@focus="handleFocus"
@keyup.enter="handleEnter"
/>
<!-- End icon -->
<div
v-if="iconEnd"
class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"
>
<i :class="[iconEnd, 'text-gray-400 dark:text-gray-500']" />
</div>
<!-- Clear button -->
<button
v-if="clearable && modelValue && !disabled && !readonly"
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
@click="clearInput"
>
<i
class="pi pi-times text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
/>
</button>
</div>
<!-- Helper text -->
<div v-if="helperText || errorMessage" :class="helperClasses">
{{ errorMessage || helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import InputText from 'primevue/inputtext'
interface PrimeInputProps {
modelValue?: string | number
label?: string
placeholder?: string
helperText?: string
errorMessage?: string
disabled?: boolean
readonly?: boolean
required?: boolean
invalid?: boolean
clearable?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'filled' | 'outlined'
iconStart?: string
iconEnd?: string
block?: boolean
}
const props = withDefaults(defineProps<PrimeInputProps>(), {
size: 'md',
variant: 'outlined',
block: true,
disabled: false,
readonly: false,
required: false,
invalid: false,
clearable: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
enter: [event: KeyboardEvent]
}>()
// Generate unique ID for accessibility
const inputId = ref(`input-${Math.random().toString(36).substr(2, 9)}`)
// Wrapper classes
const wrapperClasses = computed(() => {
const classes = []
if (props.block) {
classes.push('w-full')
}
return classes.join(' ')
})
// Label classes
const labelClasses = computed(() => {
const classes = ['block text-sm font-medium mb-2']
if (props.invalid || props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-700 dark:text-gray-300')
}
return classes.join(' ')
})
// Input classes
const inputClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-3 text-sm',
lg: 'h-12 px-4 text-base',
}
classes.push(sizeClasses[props.size])
// Icon padding adjustments
if (props.iconStart) {
classes.push('pl-10')
}
if (props.iconEnd || props.clearable) {
classes.push('pr-10')
}
// Block width
if (props.block) {
classes.push('w-full')
}
// Border radius
classes.push('rounded-md')
// Error state
if (props.invalid || props.errorMessage) {
classes.push('border-red-500 focus:border-red-500 focus:ring-red-500')
}
// Variant styling
if (props.variant === 'filled') {
classes.push('bg-gray-50 dark:bg-gray-800 border-transparent')
}
return classes.join(' ')
})
// Helper text classes
const helperClasses = computed(() => {
const classes = ['mt-2 text-sm']
if (props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-500 dark:text-gray-400')
}
return classes.join(' ')
})
// Event handlers
const handleInput = (value: string | number) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
const handleEnter = (event: KeyboardEvent) => {
emit('enter', event)
}
const clearInput = () => {
emit('update:modelValue', '')
}
</script>
<style scoped>
/* Override PrimeVue InputText styles */
:deep(.p-inputtext) {
border: 1px solid #d1d5db;
transition: all 0.2s ease-in-out;
}
:deep(.p-inputtext:enabled:hover) {
border-color: #9ca3af;
}
:deep(.p-inputtext:enabled:focus) {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Dark mode styles */
.dark :deep(.p-inputtext) {
background-color: #374151;
border-color: #4b5563;
color: #f3f4f6;
}
.dark :deep(.p-inputtext:enabled:hover) {
border-color: #6b7280;
}
.dark :deep(.p-inputtext:enabled:focus) {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.dark :deep(.p-inputtext::placeholder) {
color: #9ca3af;
}
/* Invalid state */
:deep(.p-inputtext.p-invalid) {
border-color: #ef4444;
}
:deep(.p-inputtext.p-invalid:enabled:focus) {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<div :class="wrapperClasses">
<!-- Label -->
<div v-if="label || showValue" :class="labelWrapperClasses">
<span v-if="label" :class="labelClasses">{{ label }}</span>
<span v-if="showValue" :class="valueClasses">{{ displayValue }}</span>
</div>
<!-- Progress bar -->
<ProgressBar :value="value" :class="progressClasses" :showValue="false">
<!-- Custom content slot -->
<template v-if="$slots.default" #default>
<slot />
</template>
</ProgressBar>
<!-- Helper text -->
<div v-if="helperText" :class="helperClasses">
{{ helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProgressBar from 'primevue/progressbar'
interface PrimeProgressProps {
value?: number
label?: string
helperText?: string
showValue?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'gradient' | 'striped'
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
animated?: boolean
indeterminate?: boolean
}
const props = withDefaults(defineProps<PrimeProgressProps>(), {
value: 0,
size: 'md',
variant: 'gradient',
color: 'primary',
showValue: true,
animated: false,
indeterminate: false,
})
// Wrapper classes
const wrapperClasses = computed(() => {
return 'w-full'
})
// Label wrapper classes
const labelWrapperClasses = computed(() => {
return 'flex justify-between items-center mb-2'
})
// Label classes
const labelClasses = computed(() => {
return 'text-sm font-medium text-gray-700 dark:text-gray-300'
})
// Value classes
const valueClasses = computed(() => {
return 'text-sm font-medium text-gray-500 dark:text-gray-400'
})
// Progress classes
const progressClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'h-2',
md: 'h-3',
lg: 'h-4',
}
classes.push(sizeClasses[props.size])
// Variant classes
if (props.variant === 'gradient') {
classes.push('progress-gradient')
} else if (props.variant === 'striped') {
classes.push('progress-striped')
}
// Color classes
const colorClasses = {
primary: 'progress-primary',
success: 'progress-success',
warning: 'progress-warning',
danger: 'progress-danger',
info: 'progress-info',
}
classes.push(colorClasses[props.color])
// Animation
if (props.animated) {
classes.push('progress-animated')
}
return classes.join(' ')
})
// Helper text classes
const helperClasses = computed(() => {
return 'mt-2 text-sm text-gray-500 dark:text-gray-400'
})
// Display value
const displayValue = computed(() => {
if (props.indeterminate) {
return 'Loading...'
}
return `${Math.round(props.value)}%`
})
</script>
<style scoped>
/* Override PrimeVue ProgressBar styles */
:deep(.p-progressbar) {
border-radius: 9999px;
background-color: #e5e7eb;
overflow: hidden;
}
.dark :deep(.p-progressbar) {
background-color: #374151;
}
:deep(.p-progressbar-value) {
border-radius: 9999px;
transition: width 0.3s ease-in-out;
}
/* Gradient variants */
:deep(.progress-gradient.progress-primary .p-progressbar-value) {
background: linear-gradient(90deg, #2563eb 0%, #7c3aed 100%);
}
.dark :deep(.progress-gradient.progress-primary .p-progressbar-value) {
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%);
}
:deep(.progress-gradient.progress-success .p-progressbar-value) {
background: linear-gradient(90deg, #059669 0%, #10b981 100%);
}
:deep(.progress-gradient.progress-warning .p-progressbar-value) {
background: linear-gradient(90deg, #d97706 0%, #f59e0b 100%);
}
:deep(.progress-gradient.progress-danger .p-progressbar-value) {
background: linear-gradient(90deg, #dc2626 0%, #ef4444 100%);
}
:deep(.progress-gradient.progress-info .p-progressbar-value) {
background: linear-gradient(90deg, #0891b2 0%, #06b6d4 100%);
}
/* Striped variant */
:deep(.progress-striped .p-progressbar-value) {
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 1rem 1rem;
}
/* Animated stripes */
:deep(.progress-animated.progress-striped .p-progressbar-value) {
animation: progress-bar-stripes 1s linear infinite;
}
@keyframes progress-bar-stripes {
0% {
background-position: 1rem 0;
}
100% {
background-position: 0 0;
}
}
/* Indeterminate animation */
:deep(.p-progressbar.progress-indeterminate .p-progressbar-value) {
animation: progress-indeterminate 2s ease-in-out infinite;
}
@keyframes progress-indeterminate {
0% {
width: 0%;
margin-left: 0%;
}
50% {
width: 50%;
margin-left: 25%;
}
100% {
width: 0%;
margin-left: 100%;
}
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div :class="wrapperClasses">
<!-- Label -->
<label v-if="label" :for="selectId" :class="labelClasses">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Select dropdown -->
<Dropdown
:id="selectId"
:class="selectClasses"
:modelValue="modelValue"
:options="options"
:optionLabel="optionLabel"
:optionValue="optionValue"
:optionGroupLabel="optionGroupLabel"
:optionGroupChildren="optionGroupChildren"
:placeholder="placeholder"
:disabled="disabled"
:invalid="invalid"
:filter="searchable"
:filterPlaceholder="searchPlaceholder"
:showClear="clearable"
:loading="loading"
:emptyMessage="emptyMessage"
:emptyFilterMessage="emptyFilterMessage"
@update:modelValue="handleChange"
@change="handleChange"
@filter="handleFilter"
@show="handleShow"
@hide="handleHide"
>
<!-- Custom option template -->
<template v-if="$slots.option" #option="slotProps">
<slot name="option" v-bind="slotProps" />
</template>
<!-- Custom value template -->
<template v-if="$slots.value" #value="slotProps">
<slot name="value" v-bind="slotProps" />
</template>
<!-- Custom header template -->
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<!-- Custom footer template -->
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</Dropdown>
<!-- Helper text -->
<div v-if="helperText || errorMessage" :class="helperClasses">
{{ errorMessage || helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Dropdown from 'primevue/dropdown'
interface PrimeSelectProps {
modelValue?: any
options?: any[]
optionLabel?: string
optionValue?: string
optionGroupLabel?: string
optionGroupChildren?: string
label?: string
placeholder?: string
helperText?: string
errorMessage?: string
disabled?: boolean
required?: boolean
invalid?: boolean
searchable?: boolean
clearable?: boolean
loading?: boolean
block?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'filled' | 'outlined'
emptyMessage?: string
emptyFilterMessage?: string
searchPlaceholder?: string
}
const props = withDefaults(defineProps<PrimeSelectProps>(), {
size: 'md',
variant: 'outlined',
block: true,
disabled: false,
required: false,
invalid: false,
searchable: false,
clearable: false,
loading: false,
emptyMessage: 'No results found',
emptyFilterMessage: 'No results found',
searchPlaceholder: 'Search...',
})
const emit = defineEmits<{
'update:modelValue': [value: any]
change: [event: { originalEvent: Event; value: any }]
filter: [event: { originalEvent: Event; value: string }]
show: []
hide: []
}>()
// Generate unique ID for accessibility
const selectId = ref(`select-${Math.random().toString(36).substr(2, 9)}`)
// Wrapper classes
const wrapperClasses = computed(() => {
const classes = []
if (props.block) {
classes.push('w-full')
}
return classes.join(' ')
})
// Label classes
const labelClasses = computed(() => {
const classes = ['block text-sm font-medium mb-2']
if (props.invalid || props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-700 dark:text-gray-300')
}
return classes.join(' ')
})
// Select classes
const selectClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'h-8 text-sm',
md: 'h-10 text-sm',
lg: 'h-12 text-base',
}
classes.push(sizeClasses[props.size])
// Block width
if (props.block) {
classes.push('w-full')
}
// Border radius
classes.push('rounded-md')
// Error state
if (props.invalid || props.errorMessage) {
classes.push('border-red-500')
}
return classes.join(' ')
})
// Helper text classes
const helperClasses = computed(() => {
const classes = ['mt-2 text-sm']
if (props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-500 dark:text-gray-400')
}
return classes.join(' ')
})
// Event handlers
const handleChange = (value: any) => {
emit('update:modelValue', value)
emit('change', { originalEvent: new Event('change'), value })
}
const handleFilter = (event: { originalEvent: Event; value: string }) => {
emit('filter', event)
}
const handleShow = () => {
emit('show')
}
const handleHide = () => {
emit('hide')
}
</script>
<style scoped>
/* Override PrimeVue Dropdown styles */
:deep(.p-dropdown) {
border: 1px solid #d1d5db;
transition: all 0.2s ease-in-out;
background-color: #ffffff;
}
:deep(.p-dropdown:not(.p-disabled):hover) {
border-color: #9ca3af;
}
:deep(.p-dropdown:not(.p-disabled).p-focus) {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Dark mode styles */
.dark :deep(.p-dropdown) {
background-color: #374151;
border-color: #4b5563;
color: #f3f4f6;
}
.dark :deep(.p-dropdown:not(.p-disabled):hover) {
border-color: #6b7280;
}
.dark :deep(.p-dropdown:not(.p-disabled).p-focus) {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.dark :deep(.p-dropdown-label) {
color: #f3f4f6;
}
.dark :deep(.p-dropdown-trigger) {
color: #9ca3af;
}
/* Dropdown panel */
:deep(.p-dropdown-panel) {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.dark :deep(.p-dropdown-panel) {
background-color: #374151;
border-color: #4b5563;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.3),
0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
:deep(.p-dropdown-item) {
padding: 0.75rem 1rem;
transition: all 0.2s;
}
:deep(.p-dropdown-item:hover) {
background-color: #f3f4f6;
}
.dark :deep(.p-dropdown-item) {
color: #f3f4f6;
}
.dark :deep(.p-dropdown-item:hover) {
background-color: #4b5563;
}
:deep(.p-dropdown-item.p-highlight) {
background-color: #dbeafe;
color: #1e40af;
}
.dark :deep(.p-dropdown-item.p-highlight) {
background-color: #1e40af;
color: #dbeafe;
}
/* Invalid state */
:deep(.p-dropdown.p-invalid) {
border-color: #ef4444;
}
:deep(.p-dropdown.p-invalid:not(.p-disabled).p-focus) {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<Skeleton
:class="skeletonClasses"
:width="width"
:height="height"
:borderRadius="borderRadius"
:animation="animation"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Skeleton from 'primevue/skeleton'
interface PrimeSkeletonProps {
variant?: 'text' | 'circular' | 'rectangular' | 'rounded'
size?: 'sm' | 'md' | 'lg' | 'xl'
width?: string
height?: string
animation?: 'pulse' | 'wave' | 'none'
lines?: number
}
const props = withDefaults(defineProps<PrimeSkeletonProps>(), {
variant: 'rectangular',
size: 'md',
animation: 'pulse',
lines: 1,
})
// Skeleton classes
const skeletonClasses = computed(() => {
const classes = []
// Variant classes
if (props.variant === 'text') {
classes.push('skeleton-text')
} else if (props.variant === 'circular') {
classes.push('skeleton-circular')
} else if (props.variant === 'rounded') {
classes.push('skeleton-rounded')
}
// Size classes for predefined variants
if (!props.width && !props.height) {
const sizeClasses = {
sm: props.variant === 'circular' ? 'w-8 h-8' : props.variant === 'text' ? 'h-4' : 'w-16 h-16',
md:
props.variant === 'circular' ? 'w-12 h-12' : props.variant === 'text' ? 'h-5' : 'w-24 h-24',
lg:
props.variant === 'circular' ? 'w-16 h-16' : props.variant === 'text' ? 'h-6' : 'w-32 h-32',
xl:
props.variant === 'circular' ? 'w-20 h-20' : props.variant === 'text' ? 'h-8' : 'w-40 h-40',
}
classes.push(sizeClasses[props.size])
}
return classes.join(' ')
})
// Border radius
const borderRadius = computed(() => {
if (props.variant === 'circular') return '50%'
if (props.variant === 'rounded') return '0.5rem'
if (props.variant === 'text') return '0.25rem'
return '0.375rem'
})
</script>
<style scoped>
/* Override PrimeVue Skeleton styles */
:deep(.p-skeleton) {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
}
.dark :deep(.p-skeleton) {
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
background-size: 200% 100%;
}
/* Animation variants */
:deep(.p-skeleton[data-animation='pulse']) {
animation: skeleton-pulse 2s ease-in-out infinite;
}
:deep(.p-skeleton[data-animation='wave']) {
animation: skeleton-wave 1.5s ease-in-out infinite;
}
:deep(.p-skeleton[data-animation='none']) {
animation: none;
}
/* Text variant specific styling */
.skeleton-text :deep(.p-skeleton) {
border-radius: 0.25rem;
}
/* Circular variant specific styling */
.skeleton-circular :deep(.p-skeleton) {
border-radius: 50%;
}
/* Rounded variant specific styling */
.skeleton-rounded :deep(.p-skeleton) {
border-radius: 0.5rem;
}
/* Animations */
@keyframes skeleton-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes skeleton-wave {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>

View File

@@ -0,0 +1,46 @@
// PrimeVue component exports for easy importing
export { default as PrimeButton } from './PrimeButton.vue'
export { default as PrimeCard } from './PrimeCard.vue'
export { default as PrimeInput } from './PrimeInput.vue'
export { default as PrimeBadge } from './PrimeBadge.vue'
export { default as PrimeDialog } from './PrimeDialog.vue'
export { default as PrimeSelect } from './PrimeSelect.vue'
export { default as PrimeProgress } from './PrimeProgress.vue'
export { default as PrimeSkeleton } from './PrimeSkeleton.vue'
// Re-export commonly used PrimeVue components with consistent naming
export { default as Toast } from 'primevue/toast'
export { default as Dialog } from 'primevue/dialog'
export { default as ConfirmDialog } from 'primevue/confirmdialog'
export { default as Dropdown } from 'primevue/dropdown'
export { default as MultiSelect } from 'primevue/multiselect'
export { default as Calendar } from 'primevue/calendar'
export { default as Slider } from 'primevue/slider'
export { default as ProgressBar } from 'primevue/progressbar'
export { default as Badge } from 'primevue/badge'
export { default as Chip } from 'primevue/chip'
export { default as Avatar } from 'primevue/avatar'
export { default as AvatarGroup } from 'primevue/avatargroup'
export { default as Skeleton } from 'primevue/skeleton'
export { default as DataTable } from 'primevue/datatable'
export { default as Column } from 'primevue/column'
export { default as Paginator } from 'primevue/paginator'
export { default as Menu } from 'primevue/menu'
export { default as MenuBar } from 'primevue/menubar'
export { default as ContextMenu } from 'primevue/contextmenu'
export { default as Breadcrumb } from 'primevue/breadcrumb'
export { default as Steps } from 'primevue/steps'
export { default as TabView } from 'primevue/tabview'
export { default as TabPanel } from 'primevue/tabpanel'
export { default as Accordion } from 'primevue/accordion'
export { default as AccordionTab } from 'primevue/accordiontab'
export { default as Fieldset } from 'primevue/fieldset'
export { default as Panel } from 'primevue/panel'
export { default as Splitter } from 'primevue/splitter'
export { default as SplitterPanel } from 'primevue/splitterpanel'
export { default as Divider } from 'primevue/divider'
export { default as ScrollPanel } from 'primevue/scrollpanel'
export { default as Toolbar } from 'primevue/toolbar'
export { default as Sidebar } from 'primevue/sidebar'
export { default as OverlayPanel } from 'primevue/overlaypanel'
export { default as Tooltip } from 'primevue/tooltip'

View File

@@ -1,190 +1,246 @@
<template>
<div
class="group relative bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-all duration-200 border border-gray-200 dark:border-gray-700 overflow-hidden cursor-pointer"
<Card
class="group relative overflow-hidden cursor-pointer
bg-gradient-to-br from-surface-0 via-surface-50 to-primary-50
dark:from-surface-900 dark:via-surface-950 dark:to-surface-800
border border-primary-200 dark:border-primary-800
shadow-lg shadow-primary-100/20 dark:shadow-primary-900/20
hover:shadow-xl hover:shadow-primary-200/30 dark:hover:shadow-primary-800/30
hover:scale-[1.02] transition-all duration-300 ease-out
backdrop-blur-sm"
@click="$emit('click')"
>
<!-- Ride Image -->
<div class="aspect-w-16 aspect-h-9 bg-gray-100 dark:bg-gray-700">
<div class="aspect-w-16 aspect-h-9 relative overflow-hidden">
<img
v-if="ride.image_url"
:src="ride.image_url"
:alt="ride.name"
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-200"
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-500 ease-out"
/>
<div
v-else
class="w-full h-48 flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600"
class="w-full h-48 flex items-center justify-center
bg-gradient-to-br from-primary-500 via-primary-600 to-purple-600
dark:from-primary-600 dark:via-primary-700 dark:to-purple-700"
>
<Icon name="camera" class="w-12 h-12 text-white opacity-60" />
<i class="pi pi-camera text-6xl text-primary-50 opacity-70 drop-shadow-lg"></i>
</div>
<!-- Image Overlay Gradient -->
<div class="absolute inset-0 bg-gradient-to-t from-surface-900/20 via-transparent to-transparent"></div>
</div>
<!-- Status Badge -->
<div class="absolute top-3 right-3">
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
getStatusColor(ride.status),
]"
<div class="absolute top-4 right-4 z-10">
<Badge
:severity="getStatusSeverity(ride.status)"
class="shadow-lg backdrop-blur-sm font-medium text-xs px-3 py-1.5
bg-surface-0/90 dark:bg-surface-900/90
border border-primary-200 dark:border-primary-700"
>
{{ getStatusDisplay(ride.status) }}
</span>
</Badge>
</div>
<!-- Content -->
<div class="p-4">
<!-- Ride Name and Category -->
<div class="mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 line-clamp-1">
{{ ride.name }}
</h3>
<p class="text-sm text-blue-600 dark:text-blue-400 font-medium">
{{ ride.category_display || ride.category }}
</p>
</div>
<!-- Park Name -->
<div class="flex items-center mb-3">
<Icon name="map-pin" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ ride.park_name }}
</span>
</div>
<!-- Stats Row -->
<div class="grid grid-cols-2 gap-3 mb-3 text-sm">
<!-- Height -->
<div v-if="ride.height_ft" class="flex items-center">
<Icon name="trending-up" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">{{ ride.height_ft }}ft</span>
<template #content>
<div class="p-6 space-y-4">
<!-- Ride Name and Category -->
<div class="space-y-2">
<h3 class="text-xl font-bold line-clamp-1
bg-gradient-to-r from-primary-700 via-primary-600 to-purple-600
dark:from-primary-400 dark:via-primary-300 dark:to-purple-400
bg-clip-text text-transparent">
{{ ride.name }}
</h3>
<p class="text-sm font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">
{{ ride.category_display || ride.category }}
</p>
</div>
<!-- Speed -->
<div v-if="ride.speed_mph" class="flex items-center">
<Icon name="zap" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">{{ ride.speed_mph }} mph</span>
</div>
<!-- Duration -->
<div v-if="ride.ride_duration_seconds" class="flex items-center">
<Icon name="clock" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">
{{ formatDuration(ride.ride_duration_seconds) }}
<!-- Park Name -->
<div class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-r from-surface-50 to-primary-50/50
dark:from-surface-800 dark:to-primary-900/20
border border-primary-100 dark:border-primary-800">
<i class="pi pi-map-marker text-primary-500 dark:text-primary-400"></i>
<span class="text-sm font-medium text-surface-700 dark:text-surface-300">
{{ ride.park_name }}
</span>
</div>
<!-- Capacity -->
<div v-if="ride.capacity_per_hour" class="flex items-center">
<Icon name="users" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">
{{ formatCapacity(ride.capacity_per_hour) }}/hr
</span>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-3">
<!-- Height -->
<div v-if="ride.height_ft" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-emerald-50 to-emerald-100/50
dark:from-emerald-900/20 dark:to-emerald-800/30
border border-emerald-200 dark:border-emerald-700">
<i class="pi pi-arrow-up text-emerald-600 dark:text-emerald-400"></i>
<span class="text-sm font-medium text-emerald-700 dark:text-emerald-300">{{ ride.height_ft }}ft</span>
</div>
<!-- Rating and Opening Date -->
<div class="flex items-center justify-between">
<!-- Rating -->
<div v-if="ride.average_rating" class="flex items-center">
<Icon name="star" class="w-4 h-4 text-yellow-400 mr-1" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ride.average_rating.toFixed(1) }}
</span>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-1">
({{ ride.review_count || 0 }})
</span>
<!-- Speed -->
<div v-if="ride.speed_mph" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-amber-50 to-amber-100/50
dark:from-amber-900/20 dark:to-amber-800/30
border border-amber-200 dark:border-amber-700">
<i class="pi pi-bolt text-amber-600 dark:text-amber-400"></i>
<span class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ ride.speed_mph }} mph</span>
</div>
<!-- Duration -->
<div v-if="ride.ride_duration_seconds" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-blue-50 to-blue-100/50
dark:from-blue-900/20 dark:to-blue-800/30
border border-blue-200 dark:border-blue-700">
<i class="pi pi-clock text-blue-600 dark:text-blue-400"></i>
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">
{{ formatDuration(ride.ride_duration_seconds) }}
</span>
</div>
<!-- Capacity -->
<div v-if="ride.capacity_per_hour" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-purple-50 to-purple-100/50
dark:from-purple-900/20 dark:to-purple-800/30
border border-purple-200 dark:border-purple-700">
<i class="pi pi-users text-purple-600 dark:text-purple-400"></i>
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
{{ formatCapacity(ride.capacity_per_hour) }}/hr
</span>
</div>
</div>
<!-- Opening Date -->
<div v-if="ride.opening_date" class="text-xs text-gray-500 dark:text-gray-400">
Opened {{ formatYear(ride.opening_date) }}
</div>
</div>
<!-- Description Preview -->
<div
v-if="ride.description"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ ride.description }}
</p>
</div>
<!-- Manufacturer/Designer -->
<div
v-if="ride.manufacturer_name || ride.designer_name"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<div class="flex flex-wrap gap-2 text-xs">
<span
v-if="ride.manufacturer_name"
class="inline-flex items-center px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
<!-- Rating and Opening Date -->
<div class="flex items-center justify-between p-3 rounded-lg
bg-gradient-to-r from-surface-50 via-surface-25 to-primary-50
dark:from-surface-800 dark:via-surface-850 dark:to-primary-900/30
border border-primary-100 dark:border-primary-800">
<!-- Rating -->
<div
v-if="ride.average_rating && typeof ride.average_rating === 'number'"
class="flex items-center space-x-1"
>
<Icon name="building" class="w-3 h-3 mr-1" />
{{ ride.manufacturer_name }}
</span>
<span
v-if="ride.designer_name"
class="inline-flex items-center px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<Icon name="user" class="w-3 h-3 mr-1" />
{{ ride.designer_name }}
</span>
</div>
</div>
<i class="pi pi-star-fill text-yellow-500 text-base drop-shadow-sm"></i>
<span class="text-sm font-bold text-surface-800 dark:text-surface-200">
{{ ride.average_rating.toFixed(1) }}
</span>
<span class="text-xs text-surface-600 dark:text-surface-400">
({{ ride.review_count || 0 }})
</span>
</div>
<!-- Special Features -->
<div
v-if="hasSpecialFeatures"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<div class="flex flex-wrap gap-1.5">
<span
<!-- Opening Date -->
<div v-if="ride.opening_date" class="text-xs font-medium text-surface-600 dark:text-surface-400">
Opened {{ formatYear(ride.opening_date) }}
</div>
</div>
<!-- Description Preview -->
<div v-if="ride.description" class="p-3 rounded-lg
bg-gradient-to-br from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
border border-surface-200 dark:border-surface-700">
<p class="text-sm text-surface-700 dark:text-surface-300 line-clamp-2 leading-relaxed">
{{ ride.description }}
</p>
</div>
<!-- Manufacturer/Designer -->
<div
v-if="ride.manufacturer_name || ride.designer_name"
class="space-y-2"
>
<div class="flex flex-wrap gap-2">
<Badge
v-if="ride.manufacturer_name"
class="bg-gradient-to-r from-slate-100 to-slate-200
dark:from-slate-800 dark:to-slate-700
text-slate-700 dark:text-slate-300
border border-slate-300 dark:border-slate-600
text-xs font-medium px-2 py-1"
>
<i class="pi pi-building mr-1"></i>
{{ ride.manufacturer_name }}
</Badge>
<Badge
v-if="ride.designer_name"
class="bg-gradient-to-r from-indigo-100 to-indigo-200
dark:from-indigo-800 dark:to-indigo-700
text-indigo-700 dark:text-indigo-300
border border-indigo-300 dark:border-indigo-600
text-xs font-medium px-2 py-1"
>
<i class="pi pi-user mr-1"></i>
{{ ride.designer_name }}
</Badge>
</div>
</div>
<!-- Special Features -->
<div v-if="hasSpecialFeatures" class="flex flex-wrap gap-2">
<Badge
v-if="ride.inversions && ride.inversions > 0"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200"
class="bg-gradient-to-r from-purple-100 to-purple-200
dark:from-purple-800 dark:to-purple-700
text-purple-800 dark:text-purple-200
border border-purple-300 dark:border-purple-600
text-xs font-medium px-2 py-1"
>
{{ ride.inversions }} inversion{{ ride.inversions !== 1 ? "s" : "" }}
</span>
<span
{{ ride.inversions }} inversion{{ ride.inversions !== 1 ? 's' : '' }}
</Badge>
<Badge
v-if="ride.launch_type"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200"
class="bg-gradient-to-r from-orange-100 to-orange-200
dark:from-orange-800 dark:to-orange-700
text-orange-800 dark:text-orange-200
border border-orange-300 dark:border-orange-600
text-xs font-medium px-2 py-1"
>
{{ ride.launch_type }} launch
</span>
<span
</Badge>
<Badge
v-if="ride.track_material"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200"
class="bg-gradient-to-r from-green-100 to-green-200
dark:from-green-800 dark:to-green-700
text-green-800 dark:text-green-200
border border-green-300 dark:border-green-600
text-xs font-medium px-2 py-1"
>
{{ ride.track_material }}
</span>
</Badge>
</div>
</div>
</div>
</template>
<!-- Hover Overlay -->
<!-- Enhanced Hover Overlay -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-5 transition-all duration-200 pointer-events-none"
class="absolute inset-0 bg-gradient-to-br from-primary-500/5 via-transparent to-purple-500/5
opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none
backdrop-blur-[0.5px]"
></div>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Ride } from "@/types";
import Icon from "@/components/ui/Icon.vue";
import { computed } from 'vue'
import type { Ride } from '@/types'
import Card from 'primevue/card'
import Badge from 'primevue/badge'
// Using PrimeIcons instead of lucide-vue-next
// Props
interface Props {
ride: Ride;
ride: Ride
}
const props = defineProps<Props>();
const props = defineProps<Props>()
// Emits
defineEmits<{
click: [];
}>();
click: []
}>()
// Computed properties
const hasSpecialFeatures = computed(() => {
@@ -192,69 +248,69 @@ const hasSpecialFeatures = computed(() => {
(props.ride.inversions && props.ride.inversions > 0) ||
props.ride.launch_type ||
props.ride.track_material
);
});
)
})
// Methods
const getStatusColor = (status: string) => {
const getStatusSeverity = (status: string) => {
switch (status?.toLowerCase()) {
case "operating":
return "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200";
case "closed":
case "permanently_closed":
return "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200";
case "under_construction":
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200";
case "seasonal":
return "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200";
case "maintenance":
return "bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200";
case 'operating':
return 'success'
case 'closed':
case 'permanently_closed':
return 'danger'
case 'under_construction':
return 'info'
case 'seasonal':
return 'warning'
case 'maintenance':
return 'secondary'
default:
return "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200";
return 'secondary'
}
};
}
const getStatusDisplay = (status: string) => {
switch (status?.toLowerCase()) {
case "operating":
return "Operating";
case "closed":
return "Closed";
case "permanently_closed":
return "Permanently Closed";
case "under_construction":
return "Under Construction";
case "seasonal":
return "Seasonal";
case "maintenance":
return "Maintenance";
case 'operating':
return 'Operating'
case 'closed':
return 'Closed'
case 'permanently_closed':
return 'Permanently Closed'
case 'under_construction':
return 'Under Construction'
case 'seasonal':
return 'Seasonal'
case 'maintenance':
return 'Maintenance'
default:
return status || "Unknown";
return status || 'Unknown'
}
};
}
const formatDuration = (seconds: number) => {
if (seconds < 60) {
return `${seconds}s`;
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (remainingSeconds === 0) {
return `${minutes}m`;
return `${minutes}m`
}
return `${minutes}m ${remainingSeconds}s`;
};
return `${minutes}m ${remainingSeconds}s`
}
const formatCapacity = (capacity: number) => {
if (capacity >= 1000) {
return `${(capacity / 1000).toFixed(1)}k`;
return `${(capacity / 1000).toFixed(1)}k`
}
return capacity.toString();
};
return capacity.toString()
}
const formatYear = (dateString: string) => {
return new Date(dateString).getFullYear();
};
return new Date(dateString).getFullYear()
}
</script>
<style scoped>

View File

@@ -3,7 +3,7 @@
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center space-x-3">
<Icon name="spinner" class="w-6 h-6 text-blue-600 animate-spin" />
<i class="pi pi-spinner pi-spin text-blue-600 text-xl"></i>
<span class="text-gray-600 dark:text-gray-300">Loading rides...</span>
</div>
</div>
@@ -14,10 +14,8 @@
class="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800"
>
<div class="flex items-center">
<Icon name="exclamation-triangle" class="w-5 h-5 text-red-500 mr-2" />
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error loading rides
</h3>
<i class="pi pi-exclamation-triangle text-red-500 mr-2"></i>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">Error loading rides</h3>
</div>
<p class="mt-2 text-sm text-red-700 dark:text-red-300">{{ error }}</p>
<button
@@ -34,10 +32,8 @@
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center space-x-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-900 dark:text-gray-100">{{
totalCount
}}</span>
{{ totalCount === 1 ? "ride" : "rides" }} found
<span class="font-medium text-gray-900 dark:text-gray-100">{{ totalCount }}</span>
{{ totalCount === 1 ? 'ride' : 'rides' }} found
<span v-if="hasActiveFilters" class="text-gray-500 dark:text-gray-400">
with active filters
</span>
@@ -49,16 +45,13 @@
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200"
>
{{ activeFilterCount }}
{{ activeFilterCount === 1 ? "filter" : "filters" }} active
{{ activeFilterCount === 1 ? 'filter' : 'filters' }} active
</span>
</div>
<!-- Sort Controls -->
<div class="flex items-center space-x-2">
<label
for="sort-select"
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">
Sort by:
</label>
<select
@@ -83,9 +76,7 @@
<!-- Active Filters Display -->
<div v-if="activeFilters.length > 0" class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Active filters:</span
>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Active filters:</span>
<ActiveFilterChip
v-for="filter in activeFilters"
:key="filter.key"
@@ -102,10 +93,7 @@
</div>
<!-- Ride Grid -->
<div
v-if="rides.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<div v-if="rides.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<RideCard
v-for="ride in rides"
:key="ride.id"
@@ -116,13 +104,8 @@
<!-- No Results -->
<div v-else class="text-center py-12">
<Icon
name="search"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No rides found
</h3>
<i class="pi pi-search mx-auto text-4xl text-gray-400 dark:text-gray-500 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No rides found</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
No rides match your current search criteria.
</p>
@@ -174,7 +157,7 @@
:disabled="currentPage <= 1"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon name="chevron-left" class="h-5 w-5" />
<i class="pi pi-chevron-left"></i>
</button>
<button
@@ -196,7 +179,7 @@
:disabled="currentPage >= totalPages"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon name="chevron-right" class="h-5 w-5" />
<i class="pi pi-chevron-right"></i>
</button>
</nav>
</div>
@@ -206,13 +189,8 @@
<!-- Empty State (no filters) -->
<div v-else class="text-center py-12">
<Icon
name="search"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Find amazing rides
</h3>
<i class="pi pi-search mx-auto text-4xl text-gray-400 dark:text-gray-500 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Find amazing rides</h3>
<p class="text-gray-600 dark:text-gray-400">
Use the search and filters to discover rides that match your interests.
</p>
@@ -221,49 +199,41 @@
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import type { Ride } from "@/types";
import Icon from "@/components/ui/Icon.vue";
import ActiveFilterChip from "@/components/filters/ActiveFilterChip.vue";
import RideCard from "@/components/rides/RideCard.vue";
import { computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useRideFilteringStore } from '@/stores/rideFiltering'
import type { Ride } from '@/types'
import ActiveFilterChip from '@/components/filters/ActiveFilterChip.vue'
import RideCard from '@/components/rides/RideCard.vue'
// Props
interface Props {
parkSlug?: string;
parkSlug?: string
}
const props = withDefaults(defineProps<Props>(), {
parkSlug: undefined,
});
})
// Composables
const router = useRouter();
const rideFilteringStore = useRideFilteringStore();
const router = useRouter()
const rideFilteringStore = useRideFilteringStore()
// Store state
const {
rides,
isLoading,
error,
totalCount,
totalPages,
currentPage,
filters,
} = storeToRefs(rideFilteringStore);
const { rides, isLoading, error, totalCount, totalPages, currentPage, filters } =
storeToRefs(rideFilteringStore)
// Computed properties
const currentSort = computed({
get: () => filters.value.sort,
set: (value: string) => {
rideFilteringStore.updateFilters({ sort: value });
rideFilteringStore.updateFilters({ sort: value })
},
});
})
const hasActiveFilters = computed(() => {
const f = filters.value;
const f = filters.value
return !!(
f.search ||
f.categories.length > 0 ||
@@ -283,234 +253,234 @@ const hasActiveFilters = computed(() => {
f.openingDateRange[1] ||
f.closingDateRange[0] ||
f.closingDateRange[1]
);
});
)
})
const activeFilterCount = computed(() => {
const f = filters.value;
let count = 0;
const f = filters.value
let count = 0
if (f.search) count++;
if (f.categories.length > 0) count++;
if (f.manufacturers.length > 0) count++;
if (f.designers.length > 0) count++;
if (f.parks.length > 0) count++;
if (f.status.length > 0) count++;
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++;
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++;
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++;
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++;
if (f.openingDateRange[0] || f.openingDateRange[1]) count++;
if (f.closingDateRange[0] || f.closingDateRange[1]) count++;
if (f.search) count++
if (f.categories.length > 0) count++
if (f.manufacturers.length > 0) count++
if (f.designers.length > 0) count++
if (f.parks.length > 0) count++
if (f.status.length > 0) count++
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++
if (f.openingDateRange[0] || f.openingDateRange[1]) count++
if (f.closingDateRange[0] || f.closingDateRange[1]) count++
return count;
});
return count
})
const activeFilters = computed(() => {
const f = filters.value;
const active: Array<{ key: string; label: string; value: string }> = [];
const f = filters.value
const active: Array<{ key: string; label: string; value: string }> = []
if (f.search) {
active.push({ key: "search", label: "Search", value: f.search });
active.push({ key: 'search', label: 'Search', value: f.search })
}
if (f.categories.length > 0) {
active.push({
key: "categories",
label: "Categories",
key: 'categories',
label: 'Categories',
value: `${f.categories.length} selected`,
});
})
}
if (f.manufacturers.length > 0) {
active.push({
key: "manufacturers",
label: "Manufacturers",
key: 'manufacturers',
label: 'Manufacturers',
value: `${f.manufacturers.length} selected`,
});
})
}
if (f.designers.length > 0) {
active.push({
key: "designers",
label: "Designers",
key: 'designers',
label: 'Designers',
value: `${f.designers.length} selected`,
});
})
}
if (f.parks.length > 0) {
active.push({ key: "parks", label: "Parks", value: `${f.parks.length} selected` });
active.push({ key: 'parks', label: 'Parks', value: `${f.parks.length} selected` })
}
if (f.status.length > 0) {
active.push({ key: "status", label: "Status", value: `${f.status.length} selected` });
active.push({ key: 'status', label: 'Status', value: `${f.status.length} selected` })
}
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) {
active.push({
key: "height",
label: "Height",
key: 'height',
label: 'Height',
value: `${f.heightRange[0]}-${f.heightRange[1]} ft`,
});
})
}
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) {
active.push({
key: "speed",
label: "Speed",
key: 'speed',
label: 'Speed',
value: `${f.speedRange[0]}-${f.speedRange[1]} mph`,
});
})
}
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) {
active.push({
key: "capacity",
label: "Capacity",
key: 'capacity',
label: 'Capacity',
value: `${f.capacityRange[0]}-${f.capacityRange[1]}/hr`,
});
})
}
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) {
active.push({
key: "duration",
label: "Duration",
key: 'duration',
label: 'Duration',
value: `${f.durationRange[0]}-${f.durationRange[1]}s`,
});
})
}
if (f.openingDateRange[0] || f.openingDateRange[1]) {
const start = f.openingDateRange[0] || "earliest";
const end = f.openingDateRange[1] || "latest";
active.push({ key: "opening", label: "Opening Date", value: `${start} - ${end}` });
const start = f.openingDateRange[0] || 'earliest'
const end = f.openingDateRange[1] || 'latest'
active.push({ key: 'opening', label: 'Opening Date', value: `${start} - ${end}` })
}
if (f.closingDateRange[0] || f.closingDateRange[1]) {
const start = f.closingDateRange[0] || "earliest";
const end = f.closingDateRange[1] || "latest";
active.push({ key: "closing", label: "Closing Date", value: `${start} - ${end}` });
const start = f.closingDateRange[0] || 'earliest'
const end = f.closingDateRange[1] || 'latest'
active.push({ key: 'closing', label: 'Closing Date', value: `${start} - ${end}` })
}
return active;
});
return active
})
const startItem = computed(() => {
return (currentPage.value - 1) * 20 + 1;
});
return (currentPage.value - 1) * 20 + 1
})
const endItem = computed(() => {
return Math.min(currentPage.value * 20, totalCount.value);
});
return Math.min(currentPage.value * 20, totalCount.value)
})
const visiblePages = computed(() => {
const total = totalPages.value;
const current = currentPage.value;
const pages: number[] = [];
const total = totalPages.value
const current = currentPage.value
const pages: number[] = []
// Always show first page
if (total >= 1) pages.push(1);
if (total >= 1) pages.push(1)
// Show pages around current page
const start = Math.max(2, current - 2);
const end = Math.min(total - 1, current + 2);
const start = Math.max(2, current - 2)
const end = Math.min(total - 1, current + 2)
// Add ellipsis if there's a gap
if (start > 2) pages.push(-1); // -1 represents ellipsis
if (start > 2) pages.push(-1) // -1 represents ellipsis
// Add pages around current
for (let i = start; i <= end; i++) {
if (i > 1 && i < total) pages.push(i);
if (i > 1 && i < total) pages.push(i)
}
// Add ellipsis if there's a gap
if (end < total - 1) pages.push(-1);
if (end < total - 1) pages.push(-1)
// Always show last page
if (total > 1) pages.push(total);
if (total > 1) pages.push(total)
return pages;
});
return pages
})
// Methods
const handleSortChange = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
};
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
}
const removeFilter = (filter: { key: string; label: string; value: string }) => {
const updates: any = {};
const updates: any = {}
switch (filter.key) {
case "search":
updates.search = "";
break;
case "categories":
updates.categories = [];
break;
case "manufacturers":
updates.manufacturers = [];
break;
case "designers":
updates.designers = [];
break;
case "parks":
updates.parks = [];
break;
case "status":
updates.status = [];
break;
case "height":
updates.heightRange = [0, 500];
break;
case "speed":
updates.speedRange = [0, 200];
break;
case "capacity":
updates.capacityRange = [0, 10000];
break;
case "duration":
updates.durationRange = [0, 600];
break;
case "opening":
updates.openingDateRange = [null, null];
break;
case "closing":
updates.closingDateRange = [null, null];
break;
case 'search':
updates.search = ''
break
case 'categories':
updates.categories = []
break
case 'manufacturers':
updates.manufacturers = []
break
case 'designers':
updates.designers = []
break
case 'parks':
updates.parks = []
break
case 'status':
updates.status = []
break
case 'height':
updates.heightRange = [0, 500]
break
case 'speed':
updates.speedRange = [0, 200]
break
case 'capacity':
updates.capacityRange = [0, 10000]
break
case 'duration':
updates.durationRange = [0, 600]
break
case 'opening':
updates.openingDateRange = [null, null]
break
case 'closing':
updates.closingDateRange = [null, null]
break
}
rideFilteringStore.updateFilters(updates);
};
rideFilteringStore.updateFilters(updates)
}
const clearAllFilters = () => {
rideFilteringStore.resetFilters();
};
rideFilteringStore.resetFilters()
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
rideFilteringStore.updateFilters({ page });
rideFilteringStore.updateFilters({ page })
}
};
}
const retrySearch = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
};
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
}
const handleRideClick = (ride: Ride) => {
if (props.parkSlug) {
router.push(`/parks/${props.parkSlug}/rides/${ride.slug}`);
router.push(`/parks/${props.parkSlug}/rides/${ride.slug}`)
} else {
router.push(`/rides/${ride.slug}`);
router.push(`/rides/${ride.slug}`)
}
};
}
// Watch for filter changes
watch(
() => filters.value,
() => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
},
{ deep: true }
);
{ deep: true },
)
// Initialize search on mount
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div class="p-6 space-y-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">PrimeVue Component Test</h2>
<!-- Theme Controller Test -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Theme Controller</h3>
<PrimeThemeController variant="dropdown" show-text />
</div>
<!-- Button Tests -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Buttons</h3>
<div class="flex flex-wrap gap-4">
<PrimeButton variant="primary">Primary Button</PrimeButton>
<PrimeButton variant="secondary">Secondary Button</PrimeButton>
<PrimeButton variant="outline">Outline Button</PrimeButton>
<PrimeButton variant="ghost">Ghost Button</PrimeButton>
<PrimeButton variant="destructive">Destructive Button</PrimeButton>
<PrimeButton variant="primary" loading>Loading Button</PrimeButton>
<PrimeButton variant="primary" icon-start="pi pi-search" icon-only />
</div>
</div>
<!-- Card Tests -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Cards</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<PrimeCard title="Default Card" variant="default">
<p>This is a default card with some content.</p>
<template #footer>
<PrimeButton variant="primary" size="small">Action</PrimeButton>
</template>
</PrimeCard>
<PrimeCard title="Featured Card" variant="featured" interactive>
<p>This is a featured card with interactive hover effects.</p>
</PrimeCard>
</div>
</div>
<!-- Input Tests -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Inputs</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<PrimeInput
v-model="testInput1"
label="Basic Input"
placeholder="Enter some text..."
helper-text="This is helper text"
/>
<PrimeInput
v-model="testInput2"
label="Input with Icons"
placeholder="Search..."
icon-start="pi pi-search"
clearable
/>
<PrimeInput
v-model="testInput3"
label="Error State"
placeholder="This has an error"
error-message="This field is required"
invalid
/>
</div>
</div>
<!-- Native PrimeVue Components Test -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Native PrimeVue Components</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Dropdown
v-model="selectedOption"
:options="dropdownOptions"
option-label="label"
option-value="value"
placeholder="Select an option"
class="w-full"
/>
<Calendar v-model="selectedDate" placeholder="Select a date" class="w-full" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { PrimeButton, PrimeCard, PrimeInput, Dropdown, Calendar } from '@/components/primevue'
import PrimeThemeController from '@/components/layout/PrimeThemeController.vue'
// Test data
const testInput1 = ref('')
const testInput2 = ref('')
const testInput3 = ref('')
const selectedOption = ref(null)
const selectedDate = ref(null)
const dropdownOptions = [
{ label: 'Option 1', value: 'opt1' },
{ label: 'Option 2', value: 'opt2' },
{ label: 'Option 3', value: 'opt3' },
]
</script>

View File

@@ -1,123 +0,0 @@
<template>
<span :class="badgeClasses">
<slot />
<button
v-if="removable"
@click="$emit('remove')"
:class="removeButtonClasses"
type="button"
:aria-label="`Remove ${$slots.default?.[0]?.children || 'badge'}`"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface BadgeProps {
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info'
size?: 'sm' | 'md' | 'lg'
rounded?: boolean
outline?: boolean
removable?: boolean
}
const props = withDefaults(defineProps<BadgeProps>(), {
variant: 'default',
size: 'md',
rounded: true,
outline: false,
removable: false,
})
const emit = defineEmits<{
remove: []
}>()
// Base badge classes
const baseClasses = 'inline-flex items-center font-medium transition-colors'
// Variant classes
const variantClasses = computed(() => {
if (props.outline) {
const outlineVariants = {
default:
'border border-gray-300 text-gray-700 bg-transparent dark:border-gray-600 dark:text-gray-300',
primary:
'border border-blue-300 text-blue-700 bg-transparent dark:border-blue-600 dark:text-blue-300',
secondary:
'border border-gray-300 text-gray-600 bg-transparent dark:border-gray-600 dark:text-gray-400',
success:
'border border-green-300 text-green-700 bg-transparent dark:border-green-600 dark:text-green-300',
warning:
'border border-yellow-300 text-yellow-700 bg-transparent dark:border-yellow-600 dark:text-yellow-300',
error:
'border border-red-300 text-red-700 bg-transparent dark:border-red-600 dark:text-red-300',
info: 'border border-cyan-300 text-cyan-700 bg-transparent dark:border-cyan-600 dark:text-cyan-300',
}
return outlineVariants[props.variant]
}
const variants = {
default: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
primary: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
secondary: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
success: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
error: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
info: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200',
}
return variants[props.variant]
})
// Size classes
const sizeClasses = computed(() => {
const sizes = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
}
return sizes[props.size]
})
// Rounded classes
const roundedClasses = computed(() => {
if (!props.rounded) return 'rounded-none'
const rounded = {
sm: 'rounded-md',
md: 'rounded-lg',
lg: 'rounded-xl',
}
return rounded[props.size]
})
// Remove button classes
const removeButtonClasses = computed(() => {
let classes =
'ml-1 inline-flex items-center justify-center rounded-full hover:bg-black/10 dark:hover:bg-white/10 transition-colors'
if (props.size === 'sm') {
classes += ' h-4 w-4'
} else if (props.size === 'md') {
classes += ' h-5 w-5'
} else {
classes += ' h-6 w-6'
}
return classes
})
// Combined badge classes
const badgeClasses = computed(() => {
return [baseClasses, variantClasses.value, sizeClasses.value, roundedClasses.value].join(' ')
})
</script>

View File

@@ -1,172 +0,0 @@
<template>
<component
:is="componentTag"
:class="buttonClasses"
:disabled="disabled || loading"
:type="type"
:href="href"
:target="target"
:to="to"
@click="handleClick"
>
<!-- Loading spinner -->
<div
v-if="loading"
class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"
:class="{ 'mr-0': iconOnly }"
/>
<!-- Start icon -->
<component v-if="iconStart && !loading" :is="iconStart" :class="iconClasses" />
<!-- Button text -->
<span v-if="!iconOnly" :class="{ 'sr-only': loading }">
<slot />
</span>
<!-- End icon -->
<component v-if="iconEnd && !loading" :is="iconEnd" :class="iconClasses" />
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
disabled?: boolean
loading?: boolean
block?: boolean
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
iconStart?: any
iconEnd?: any
iconOnly?: boolean
href?: string
to?: string | object
target?: string
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
block: false,
rounded: 'md',
iconOnly: false,
type: 'button',
})
const emit = defineEmits<{
click: [event: Event]
}>()
// Determine component tag
const componentTag = computed(() => {
if (props.href) return 'a'
if (props.to) return 'router-link'
return 'button'
})
// Base button classes
const baseClasses =
'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
// Variant classes
const variantClasses = computed(() => {
const variants = {
primary:
'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 dark:bg-blue-500 dark:hover:bg-blue-600',
secondary:
'bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700',
outline:
'border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800',
ghost:
'bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-800',
link: 'bg-transparent text-blue-600 underline-offset-4 hover:underline dark:text-blue-400',
destructive:
'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 dark:bg-red-500 dark:hover:bg-red-600',
}
return variants[props.variant]
})
// Size classes
const sizeClasses = computed(() => {
if (props.iconOnly) {
const iconOnlySizes = {
xs: 'h-6 w-6 p-1',
sm: 'h-8 w-8 p-1.5',
md: 'h-10 w-10 p-2',
lg: 'h-12 w-12 p-2.5',
xl: 'h-14 w-14 p-3',
}
return iconOnlySizes[props.size]
}
const sizes = {
xs: 'h-6 px-2 py-1 text-xs',
sm: 'h-8 px-3 py-1.5 text-sm',
md: 'h-10 px-4 py-2 text-sm',
lg: 'h-12 px-6 py-3 text-base',
xl: 'h-14 px-8 py-4 text-lg',
}
return sizes[props.size]
})
// Rounded classes
const roundedClasses = computed(() => {
const rounded = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
}
return rounded[props.rounded]
})
// Block classes
const blockClasses = computed(() => {
return props.block ? 'w-full' : ''
})
// Icon classes
const iconClasses = computed(() => {
const iconSizes = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-6 w-6',
}
let classes = iconSizes[props.size]
if (!props.iconOnly) {
if (props.iconStart) classes += ' mr-2'
if (props.iconEnd) classes += ' ml-2'
}
return classes
})
// Combined button classes
const buttonClasses = computed(() => {
return [
baseClasses,
variantClasses.value,
sizeClasses.value,
roundedClasses.value,
blockClasses.value,
].join(' ')
})
// Handle click events
const handleClick = (event: Event) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>

View File

@@ -1,223 +0,0 @@
<template>
<div :class="cardClasses">
<!-- Header -->
<div v-if="title || $slots.header" :class="headerClasses">
<slot name="header">
<h3 v-if="title" :class="titleClasses">
{{ title }}
</h3>
</slot>
</div>
<!-- Content -->
<div v-if="$slots.default" :class="contentClasses">
<slot />
</div>
<!-- Footer -->
<div v-if="$slots.footer" :class="footerClasses">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface CardProps {
variant?: "default" | "outline" | "ghost" | "elevated" | "featured";
size?: "sm" | "md" | "lg" | "xl";
title?: string;
padding?: "none" | "sm" | "md" | "lg" | "xl";
rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl";
shadow?: "none" | "sm" | "md" | "lg" | "xl" | "2xl";
hover?: boolean;
interactive?: boolean;
bordered?: boolean;
}
const props = withDefaults(defineProps<CardProps>(), {
variant: "default",
size: "md",
padding: "md",
rounded: "lg",
shadow: "sm",
hover: false,
interactive: false,
bordered: true,
});
// Base card classes - Consistent background for both light and dark modes
const baseClasses = "bg-white dark:bg-gray-800 transition-all duration-200 ease-in-out";
// Variant classes
const variantClasses = computed(() => {
const variants = {
default: props.bordered ? "border border-gray-200 dark:border-gray-700" : "border-0",
outline: "border-2 border-gray-300 dark:border-gray-600",
ghost: "border-0 bg-transparent dark:bg-transparent",
elevated: "border-0",
featured:
"border border-blue-200 dark:border-blue-800 bg-gradient-to-br from-blue-50 to-white dark:from-blue-950 dark:to-gray-800",
};
return variants[props.variant];
});
// Shadow classes
const shadowClasses = computed(() => {
if (props.variant === "ghost") return "";
const shadows = {
none: "",
sm: "shadow-sm hover:shadow-md",
md: "shadow-md hover:shadow-lg",
lg: "shadow-lg hover:shadow-xl",
xl: "shadow-xl hover:shadow-2xl",
"2xl": "shadow-2xl hover:shadow-2xl",
};
return shadows[props.shadow];
});
// Rounded classes
const roundedClasses = computed(() => {
const rounded = {
none: "rounded-none",
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
xl: "rounded-xl",
"2xl": "rounded-2xl",
};
return rounded[props.rounded];
});
// Hover classes
const hoverClasses = computed(() => {
if (!props.hover && !props.interactive) return "";
let classes = "";
if (props.hover || props.interactive) {
if (props.variant !== "ghost") {
classes += " hover:border-gray-300 dark:hover:border-gray-600";
}
if (props.variant === "featured") {
classes +=
" hover:from-blue-100 hover:to-blue-50 dark:hover:from-blue-900 dark:hover:to-gray-700";
}
}
if (props.interactive) {
classes +=
" cursor-pointer hover:scale-[1.01] active:scale-[0.99] hover:-translate-y-0.5";
}
return classes;
});
// Padding classes for different sections
const paddingClasses = computed(() => {
const paddings = {
none: "",
sm: "p-3",
md: "p-4",
lg: "p-6",
xl: "p-8",
};
return paddings[props.padding];
});
const headerPadding = computed(() => {
if (props.padding === "none") return "";
const paddings = {
sm: "px-3 pt-3",
md: "px-4 pt-4",
lg: "px-6 pt-6",
xl: "px-8 pt-8",
};
return paddings[props.padding];
});
const contentPadding = computed(() => {
if (props.padding === "none") return "";
const hasHeader = props.title || props.$slots?.header;
const hasFooter = props.$slots?.footer;
let classes = "";
if (props.padding === "sm") {
classes = "px-3";
if (!hasHeader) classes += " pt-3";
if (!hasFooter) classes += " pb-3";
} else if (props.padding === "md") {
classes = "px-4";
if (!hasHeader) classes += " pt-4";
if (!hasFooter) classes += " pb-4";
} else if (props.padding === "lg") {
classes = "px-6";
if (!hasHeader) classes += " pt-6";
if (!hasFooter) classes += " pb-6";
} else if (props.padding === "xl") {
classes = "px-8";
if (!hasHeader) classes += " pt-8";
if (!hasFooter) classes += " pb-8";
}
return classes;
});
const footerPadding = computed(() => {
if (props.padding === "none") return "";
const paddings = {
sm: "px-3 pb-3",
md: "px-4 pb-4",
lg: "px-6 pb-6",
xl: "px-8 pb-8",
};
return paddings[props.padding];
});
// Combined classes
const cardClasses = computed(() => {
return [
baseClasses,
variantClasses.value,
shadowClasses.value,
roundedClasses.value,
hoverClasses.value,
props.padding === "none" ? "" : "",
]
.filter(Boolean)
.join(" ");
});
const headerClasses = computed(() => {
let classes = headerPadding.value;
if (props.padding !== "none") {
classes += " border-b border-gray-200 dark:border-gray-700";
}
return classes;
});
const contentClasses = computed(() => {
return contentPadding.value;
});
const footerClasses = computed(() => {
let classes = footerPadding.value;
if (props.padding !== "none") {
classes += " border-t border-gray-200 dark:border-gray-700";
}
return classes;
});
const titleClasses = computed(() => {
const sizes = {
sm: "text-lg font-semibold leading-6",
md: "text-xl font-semibold leading-7",
lg: "text-2xl font-semibold leading-8",
xl: "text-3xl font-bold leading-9",
};
return `${sizes[props.size]} text-gray-900 dark:text-gray-100 tracking-tight`;
});
</script>

View File

@@ -1,505 +0,0 @@
<template>
<svg
:class="classes"
:width="size"
:height="size"
:viewBox="viewBox"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Search icon -->
<path
v-if="name === 'search'"
d="m21 21-6-6m2-5a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Filter icon -->
<path
v-else-if="name === 'filter'"
d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- X (close) icon -->
<g v-else-if="name === 'x'">
<path
d="m18 6-12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m6 6 12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Chevron down icon -->
<path
v-else-if="name === 'chevron-down'"
d="m6 9 6 6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron up icon -->
<path
v-else-if="name === 'chevron-up'"
d="m18 15-6-6-6 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron right icon -->
<path
v-else-if="name === 'chevron-right'"
d="m9 18 6-6-6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron left icon -->
<path
v-else-if="name === 'chevron-left'"
d="m15 18-6-6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Check icon -->
<path
v-else-if="name === 'check'"
d="M20 6 9 17l-5-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Plus icon -->
<g v-else-if="name === 'plus'">
<path
d="M12 5v14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Minus icon -->
<path
v-else-if="name === 'minus'"
d="M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Calendar icon -->
<g v-else-if="name === 'calendar'">
<path
d="M8 2v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M16 2v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
width="18"
height="18"
x="3"
y="4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
rx="2"
/>
<path
d="M3 10h18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Clock icon -->
<g v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<polyline
points="12,6 12,12 16,14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Star icon (outline) -->
<path
v-else-if="name === 'star'"
d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Star icon (filled) -->
<path
v-else-if="name === 'star-filled'"
d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2Z"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- More vertical (three dots) icon -->
<g v-else-if="name === 'more-vertical'">
<circle cx="12" cy="12" r="1" fill="currentColor" />
<circle cx="12" cy="5" r="1" fill="currentColor" />
<circle cx="12" cy="19" r="1" fill="currentColor" />
</g>
<!-- Edit icon -->
<g v-else-if="name === 'edit'">
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Copy icon -->
<g v-else-if="name === 'copy'">
<rect
width="14"
height="14"
x="8"
y="8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
rx="2"
ry="2"
/>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Trash icon -->
<g v-else-if="name === 'trash'">
<path
d="M3 6h18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="10"
x2="10"
y1="11"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="14"
x2="14"
y1="11"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Loading spinner icon -->
<path
v-else-if="name === 'loading'"
d="M21 12a9 9 0 1 1-6.219-8.56"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Settings icon -->
<g v-else-if="name === 'settings'">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
<path
d="M12 1v6m0 6v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m21 12-6-3 6-3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m9 12-6 3 6 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Reset/refresh icon -->
<g v-else-if="name === 'refresh'">
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 3v5h-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 16H3v5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Save icon -->
<g v-else-if="name === 'save'">
<path
d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="17,21 17,13 7,13 7,21"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="7,3 7,8 15,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Alert circle icon -->
<g v-else-if="name === 'alert-circle'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<line
x1="12"
x2="12"
y1="8"
y2="12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="16" r="1" fill="currentColor" />
</g>
<!-- Info icon -->
<g v-else-if="name === 'info'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path
d="M12 16v-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="8" r="1" fill="currentColor" />
</g>
<!-- External link icon -->
<g v-else-if="name === 'external-link'">
<path
d="M15 3h6v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10 14 21 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Eye icon -->
<g v-else-if="name === 'eye'">
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
</g>
<!-- Eye off icon -->
<g v-else-if="name === 'eye-off'">
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m1 1 22 22"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Fallback: question mark for unknown icons -->
<g v-else>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path
d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="17" r="1" fill="currentColor" />
</g>
</svg>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface Props {
name: string;
size?: number | string;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
});
// Computed
const viewBox = computed(() => "0 0 24 24");
const classes = computed(() => {
const baseClasses = ["inline-block", "flex-shrink-0"];
if (props.class) {
baseClasses.push(props.class);
}
return baseClasses.join(" ");
});
</script>
<style scoped>
/* Ensure icons maintain their aspect ratio */
svg {
vertical-align: middle;
}
</style>

View File

@@ -1,297 +0,0 @@
<template>
<div :class="containerClasses">
<!-- Label -->
<label v-if="label" :for="inputId" :class="labelClasses">
{{ label }}
</label>
<!-- Input container -->
<div class="relative">
<!-- Search icon -->
<div :class="searchIconClasses">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<!-- Input field -->
<input
:id="inputId"
ref="inputRef"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="inputClasses"
:aria-label="ariaLabel"
:aria-describedby="helpTextId"
@input="handleInput"
@keydown.enter="handleEnter"
@focus="focused = true"
@blur="focused = false"
/>
<!-- Clear button -->
<button
v-if="clearable && modelValue && !disabled && !readonly"
type="button"
:class="clearButtonClasses"
:aria-label="clearButtonLabel"
@click="handleClear"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- Search button -->
<button
v-if="searchButton && (!searchButton || searchButton === 'icon' || searchButton === 'text')"
type="button"
:class="searchButtonClasses"
:aria-label="searchButtonLabel"
:disabled="disabled || (!allowEmptySearch && !modelValue?.trim())"
@click="handleSearch"
>
<svg
v-if="searchButton === 'icon' || searchButton === true"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span v-if="searchButton === 'text'">Search</span>
</button>
</div>
<!-- Help text -->
<p v-if="helpText" :id="helpTextId" :class="helpTextClasses">
{{ helpText }}
</p>
<!-- Error message -->
<p v-if="error" :class="errorClasses">
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
interface SearchInputProps {
modelValue?: string
type?: 'search' | 'text'
placeholder?: string
label?: string
disabled?: boolean
readonly?: boolean
clearable?: boolean
searchButton?: boolean | 'icon' | 'text'
allowEmptySearch?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'outline'
error?: string
helpText?: string
ariaLabel?: string
clearButtonLabel?: string
searchButtonLabel?: string
debounceMs?: number
}
const props = withDefaults(defineProps<SearchInputProps>(), {
type: 'search',
placeholder: 'Search...',
clearable: true,
searchButton: false,
allowEmptySearch: true,
size: 'md',
variant: 'default',
clearButtonLabel: 'Clear search',
searchButtonLabel: 'Search',
debounceMs: 300,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
search: [value: string]
clear: []
input: [value: string]
}>()
// Refs
const inputRef = ref<HTMLInputElement>()
const focused = ref(false)
const debounceTimer = ref<number>()
// Computed IDs
const inputId = computed(() => `search-input-${Math.random().toString(36).substr(2, 9)}`)
const helpTextId = computed(() => `${inputId.value}-help`)
// Container classes
const containerClasses = computed(() => {
return 'space-y-1'
})
// Label classes
const labelClasses = computed(() => {
return 'block text-sm font-medium text-gray-700 dark:text-gray-300'
})
// Size-based classes
const sizeClasses = computed(() => {
const sizes = {
sm: {
input: 'h-8 text-sm',
padding: 'pl-8 pr-8',
icon: 'left-2',
button: 'right-1 h-6 w-6',
},
md: {
input: 'h-10 text-sm',
padding: 'pl-10 pr-10',
icon: 'left-3',
button: 'right-2 h-6 w-6',
},
lg: {
input: 'h-12 text-base',
padding: 'pl-12 pr-12',
icon: 'left-4',
button: 'right-3 h-8 w-8',
},
}
return sizes[props.size]
})
// Input classes
const inputClasses = computed(() => {
const baseClasses =
'block w-full rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2'
let variantClasses = ''
if (props.variant === 'outline') {
variantClasses = 'border-gray-300 bg-transparent dark:border-gray-600 dark:bg-transparent'
} else {
variantClasses = 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'
}
const stateClasses = props.error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 dark:border-gray-600 dark:focus:border-blue-400'
const disabledClasses = props.disabled ? 'opacity-50 cursor-not-allowed' : ''
const textClasses =
'text-gray-900 placeholder-gray-500 dark:text-gray-100 dark:placeholder-gray-400'
return [
baseClasses,
variantClasses,
stateClasses,
disabledClasses,
textClasses,
sizeClasses.value.input,
sizeClasses.value.padding,
].join(' ')
})
// Search icon classes
const searchIconClasses = computed(() => {
return `absolute ${sizeClasses.value.icon} top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500 pointer-events-none`
})
// Clear button classes
const clearButtonClasses = computed(() => {
return `absolute ${sizeClasses.value.button} top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-700`
})
// Search button classes
const searchButtonClasses = computed(() => {
const baseClasses = `absolute ${sizeClasses.value.button} top-1/2 transform -translate-y-1/2 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed`
if (props.searchButton === 'text') {
return `${baseClasses} bg-blue-600 text-white hover:bg-blue-700 px-3 py-1 text-sm font-medium`
}
return `${baseClasses} text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1 hover:bg-gray-100 dark:hover:bg-gray-700`
})
// Help text classes
const helpTextClasses = computed(() => {
return 'text-sm text-gray-500 dark:text-gray-400'
})
// Error classes
const errorClasses = computed(() => {
return 'text-sm text-red-600 dark:text-red-400'
})
// Methods
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value
emit('update:modelValue', value)
emit('input', value)
// Debounced search
if (props.debounceMs > 0) {
clearTimeout(debounceTimer.value)
debounceTimer.value = window.setTimeout(() => {
if (props.allowEmptySearch || value.trim()) {
emit('search', value)
}
}, props.debounceMs)
}
}
const handleEnter = (event: KeyboardEvent) => {
if (!props.searchButton) {
handleSearch()
}
}
const handleSearch = () => {
const value = props.modelValue || ''
if (props.allowEmptySearch || value.trim()) {
emit('search', value)
}
}
const handleClear = async () => {
emit('update:modelValue', '')
emit('clear')
emit('search', '')
await nextTick()
inputRef.value?.focus()
}
// Focus method
const focus = () => {
inputRef.value?.focus()
}
// Expose focus method
defineExpose({
focus,
})
</script>

View File

@@ -1,82 +0,0 @@
// UI Components
export { default as Badge } from './Badge.vue'
export { default as Button } from './Button.vue'
export { default as Card } from './Card.vue'
export { default as SearchInput } from './SearchInput.vue'
// Layout Components
export { default as ThemeController } from '../layout/ThemeController.vue'
export { default as Navbar } from '../layout/Navbar.vue'
// Type definitions for component props
export interface BadgeProps {
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info'
size?: 'sm' | 'md' | 'lg'
rounded?: boolean
outline?: boolean
removable?: boolean
}
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
disabled?: boolean
loading?: boolean
block?: boolean
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
iconStart?: any
iconEnd?: any
iconOnly?: boolean
href?: string
to?: string | object
target?: string
type?: 'button' | 'submit' | 'reset'
}
export interface CardProps {
variant?: 'default' | 'outline' | 'ghost' | 'elevated'
size?: 'sm' | 'md' | 'lg'
title?: string
padding?: 'none' | 'sm' | 'md' | 'lg'
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
hover?: boolean
interactive?: boolean
}
export interface SearchInputProps {
modelValue?: string
type?: 'search' | 'text'
placeholder?: string
label?: string
disabled?: boolean
readonly?: boolean
clearable?: boolean
searchButton?: boolean | 'icon' | 'text'
allowEmptySearch?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'outline'
error?: string
helpText?: string
ariaLabel?: string
clearButtonLabel?: string
searchButtonLabel?: string
debounceMs?: number
}
export interface ThemeControllerProps {
variant?: 'button' | 'dropdown'
size?: 'sm' | 'md' | 'lg'
showText?: boolean
showDropdown?: boolean
position?: 'fixed' | 'relative'
}
export interface NavbarProps {
sticky?: boolean
shadow?: boolean
height?: 'compact' | 'default' | 'comfortable'
showMobileSearch?: boolean
showThemeToggle?: boolean
showMobileMenu?: boolean
}

View File

@@ -4,14 +4,14 @@
import { ref, computed, watch, type Ref } from 'vue'
import { api } from '@/services/api'
import type {
Ride,
RideFilters,
FilterOptions,
CompanySearchResult,
import type {
Ride,
RideFilters,
FilterOptions,
CompanySearchResult,
RideModelSearchResult,
SearchSuggestion,
ApiResponse
ApiResponse,
} from '@/types'
export function useRideFiltering(initialFilters: RideFilters = {}) {
@@ -23,33 +23,33 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
const currentPage = ref(1)
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Filter state
const filters = ref<RideFilters>({ ...initialFilters })
const filterOptions = ref<FilterOptions | null>(null)
// Debounced search
const searchDebounceTimeout = ref<number | null>(null)
// Computed
const hasActiveFilters = computed(() => {
return Object.values(filters.value).some(value => {
return Object.values(filters.value).some((value) => {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
})
})
const isFirstLoad = computed(() => rides.value.length === 0 && !isLoading.value)
/**
* Build query parameters from filters
*/
const buildQueryParams = (filterData: RideFilters): Record<string, string> => {
const params: Record<string, string> = {}
Object.entries(filterData).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value)) {
if (value.length > 0) {
params[key] = value.join(',')
@@ -58,10 +58,10 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
params[key] = String(value)
}
})
return params
}
/**
* Fetch rides with current filters
*/
@@ -70,19 +70,19 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
currentPage.value = 1
filters.value.page = 1
}
isLoading.value = true
error.value = null
try {
const queryParams = buildQueryParams(filters.value)
const response = await api.client.get<ApiResponse<Ride>>('/rides/api/', queryParams)
const response = await api.client.get<ApiResponse<Ride>>('/rides/', queryParams)
rides.value = response.results
totalCount.value = response.count
hasNextPage.value = !!response.next
hasPreviousPage.value = !!response.previous
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rides'
@@ -92,27 +92,27 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
isLoading.value = false
}
}
/**
* Load more rides (pagination)
*/
const loadMore = async () => {
if (!hasNextPage.value || isLoading.value) return
currentPage.value += 1
filters.value.page = currentPage.value
isLoading.value = true
try {
const queryParams = buildQueryParams(filters.value)
const response = await api.client.get<ApiResponse<Ride>>('/rides/api/', queryParams)
const response = await api.client.get<ApiResponse<Ride>>('/rides/', queryParams)
rides.value.push(...response.results)
totalCount.value = response.count
hasNextPage.value = !!response.next
hasPreviousPage.value = !!response.previous
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load more rides'
@@ -122,13 +122,13 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
isLoading.value = false
}
}
/**
* Fetch filter options from API
*/
const fetchFilterOptions = async () => {
try {
const response = await api.client.get<FilterOptions>('/rides/api/filter-options/')
const response = await api.client.get<FilterOptions>('/rides/filter-options/')
filterOptions.value = response
return response
} catch (err) {
@@ -136,75 +136,85 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
throw err
}
}
/**
* Search companies for manufacturer/designer autocomplete
*/
const searchCompanies = async (query: string, role?: 'manufacturer' | 'designer' | 'both'): Promise<CompanySearchResult[]> => {
const searchCompanies = async (
query: string,
role?: 'manufacturer' | 'designer' | 'both',
): Promise<CompanySearchResult[]> => {
if (!query.trim()) return []
try {
const params: Record<string, string> = { q: query }
if (role) params.role = role
const response = await api.client.get<CompanySearchResult[]>('/rides/api/search/companies/', params)
const response = await api.client.get<CompanySearchResult[]>(
'/rides/search-companies/',
params,
)
return response
} catch (err) {
console.error('Error searching companies:', err)
return []
}
}
/**
* Search ride models for autocomplete
*/
const searchRideModels = async (query: string): Promise<RideModelSearchResult[]> => {
if (!query.trim()) return []
try {
const response = await api.client.get<RideModelSearchResult[]>('/rides/api/search/ride-models/', { q: query })
const response = await api.client.get<RideModelSearchResult[]>('/rides/search-ride-models/', {
q: query,
})
return response
} catch (err) {
console.error('Error searching ride models:', err)
return []
}
}
/**
* Get search suggestions
*/
const getSearchSuggestions = async (query: string): Promise<SearchSuggestion[]> => {
if (!query.trim()) return []
try {
const response = await api.client.get<SearchSuggestion[]>('/rides/api/search/suggestions/', { q: query })
const response = await api.client.get<SearchSuggestion[]>('/rides/search-suggestions/', {
q: query,
})
return response
} catch (err) {
console.error('Error getting search suggestions:', err)
return []
}
}
/**
* Update a specific filter
*/
const updateFilter = (key: keyof RideFilters, value: any) => {
filters.value = {
...filters.value,
[key]: value
[key]: value,
}
}
/**
* Update multiple filters at once
*/
const updateFilters = (newFilters: Partial<RideFilters>) => {
filters.value = {
...filters.value,
...newFilters
...newFilters,
}
}
/**
* Clear all filters
*/
@@ -212,7 +222,7 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
filters.value = {}
currentPage.value = 1
}
/**
* Clear a specific filter
*/
@@ -221,7 +231,7 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
delete newFilters[key]
filters.value = newFilters
}
/**
* Debounced search for text inputs
*/
@@ -229,13 +239,13 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
if (searchDebounceTimeout.value) {
clearTimeout(searchDebounceTimeout.value)
}
searchDebounceTimeout.value = window.setTimeout(() => {
updateFilter('search', query)
fetchRides()
}, delay)
}
/**
* Set sorting
*/
@@ -243,7 +253,7 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
updateFilter('ordering', ordering)
fetchRides()
}
/**
* Set page size
*/
@@ -251,7 +261,7 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
updateFilter('page_size', pageSize)
fetchRides()
}
/**
* Export current results
*/
@@ -260,17 +270,20 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
const queryParams = buildQueryParams({
...filters.value,
export: format,
page_size: 1000 // Export more results
page_size: 1000, // Export more results
})
const response = await fetch(`${api.getBaseUrl()}/rides/api/?${new URLSearchParams(queryParams)}`, {
headers: {
'Accept': format === 'csv' ? 'text/csv' : 'application/json'
}
})
const response = await fetch(
`${api.getBaseUrl()}/rides/?${new URLSearchParams(queryParams)}`,
{
headers: {
Accept: format === 'csv' ? 'text/csv' : 'application/json',
},
},
)
if (!response.ok) throw new Error('Export failed')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -285,23 +298,23 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
throw err
}
}
// Watch for filter changes and auto-fetch
watch(
() => filters.value,
(newFilters, oldFilters) => {
// Skip if this is the initial setup
if (!oldFilters) return
// Don't auto-fetch for search queries (use debounced search instead)
if (newFilters.search !== oldFilters.search) return
// Auto-fetch for other filter changes
fetchRides()
},
{ deep: true }
{ deep: true },
)
return {
// State
isLoading,
@@ -313,11 +326,11 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
hasPreviousPage,
filters,
filterOptions,
// Computed
hasActiveFilters,
isFirstLoad,
// Methods
fetchRides,
loadMore,
@@ -333,35 +346,38 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
setSorting,
setPageSize,
exportResults,
// Utilities
buildQueryParams
buildQueryParams,
}
}
// Park-specific ride filtering
export function useParkRideFiltering(parkSlug: string, initialFilters: RideFilters = {}) {
const baseComposable = useRideFiltering(initialFilters)
// Override the fetch method to use park-specific endpoint
const fetchRides = async (resetPagination = true) => {
if (resetPagination) {
baseComposable.currentPage.value = 1
baseComposable.filters.value.page = 1
}
baseComposable.isLoading.value = true
baseComposable.error.value = null
try {
const queryParams = baseComposable.buildQueryParams(baseComposable.filters.value)
const response = await api.client.get<ApiResponse<Ride>>(`/parks/${parkSlug}/rides/`, queryParams)
const response = await api.client.get<ApiResponse<Ride>>(
`/parks/${parkSlug}/rides/`,
queryParams,
)
baseComposable.rides.value = response.results
baseComposable.totalCount.value = response.count
baseComposable.hasNextPage.value = !!response.next
baseComposable.hasPreviousPage.value = !!response.previous
return response
} catch (err) {
baseComposable.error.value = err instanceof Error ? err.message : 'Failed to fetch park rides'
@@ -371,9 +387,9 @@ export function useParkRideFiltering(parkSlug: string, initialFilters: RideFilte
baseComposable.isLoading.value = false
}
}
return {
...baseComposable,
fetchRides
fetchRides,
}
}
}

View File

@@ -1,100 +1,146 @@
import { ref, watch, onMounted } from 'vue'
import { ref, computed, watch, readonly } from 'vue'
import { usePrimeVue } from 'primevue/config'
import PrimeVue from 'primevue/config'
// Theme state
const isDark = ref(false)
export type ThemeMode = 'light' | 'dark' | 'system'
const currentTheme = ref<ThemeMode>('system')
const systemTheme = ref<'light' | 'dark'>('light')
// Theme management composable
export function useTheme() {
// Initialize theme from localStorage or system preference
const initializeTheme = () => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
} else {
// Check system preference
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
updateTheme()
}
// Note: usePrimeVue() should only be called after PrimeVue is installed
// Use a typed ReturnType instead of `any` to satisfy ESLint/TS rules.
let $primevue: ReturnType<typeof usePrimeVue> | null = null
// Update theme in DOM and localStorage
const updateTheme = () => {
if (isDark.value) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
}
}
// Toggle theme
const toggleTheme = () => {
isDark.value = !isDark.value
updateTheme()
}
// Set specific theme
const setTheme = (theme: 'light' | 'dark') => {
isDark.value = theme === 'dark'
updateTheme()
}
// Watch for changes and update DOM
watch(isDark, updateTheme, { immediate: false })
// Listen for system theme changes
const watchSystemTheme = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
// Only update if user hasn't set a manual preference
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
}
}
mediaQuery.addEventListener('change', handleChange)
// Return cleanup function
return () => mediaQuery.removeEventListener('change', handleChange)
}
// Auto-initialize on mount
onMounted(() => {
initializeTheme()
watchSystemTheme()
// Computed property for the actual applied theme
const appliedTheme = computed(() => {
return currentTheme.value === 'system' ? systemTheme.value : currentTheme.value
})
return {
isDark,
toggleTheme,
setTheme,
initializeTheme
}
}
// Global theme utilities
export const themeUtils = {
// Get current theme
getCurrentTheme: (): 'light' | 'dark' => {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
},
// Check if dark mode is preferred by system
getSystemTheme: (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
},
// Force apply theme without composable
applyTheme: (theme: 'light' | 'dark') => {
// Apply theme to document and PrimeVue
const applyTheme = (theme: 'light' | 'dark') => {
// Update document class for Tailwind dark mode
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
localStorage.setItem('theme', theme)
// Update PrimeVue theme
if ($primevue?.changeTheme) {
const currentThemeName = theme === 'dark' ? 'thrillwiki-dark' : 'thrillwiki-light'
$primevue.changeTheme('', currentThemeName, 'theme-link', () => {
console.log(`Theme changed to ${theme}`)
})
}
}
}
// Get system theme preference
const getSystemTheme = (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
// Set theme
const setTheme = (theme: ThemeMode) => {
currentTheme.value = theme
localStorage.setItem('theme', theme)
if (theme === 'system') {
systemTheme.value = getSystemTheme()
applyTheme(systemTheme.value)
} else {
applyTheme(theme)
}
}
// Toggle between light and dark (skips system)
const toggleTheme = () => {
if (currentTheme.value === 'light') {
setTheme('dark')
} else {
setTheme('light')
}
}
// Initialize theme on mount
const initializeTheme = () => {
// Rely on the flag set in main.ts to ensure PrimeVue is installed before calling composables.
if ((window as Window & { __PRIMEVUE_INSTALLED__?: boolean }).__PRIMEVUE_INSTALLED__) {
try {
const $primevue = usePrimeVue()
defineExpose({
$primevue,
})
} catch (error) {
console.warn('PrimeVue not available for theme switching:', error)
}
}
const savedTheme = localStorage.getItem('theme') as ThemeMode | null
if (savedTheme && ['light', 'dark', 'system'].includes(savedTheme)) {
currentTheme.value = savedTheme
} else {
currentTheme.value = 'system'
}
// Set initial system theme
systemTheme.value = getSystemTheme()
// Apply the theme
if (currentTheme.value === 'system') {
applyTheme(systemTheme.value)
} else {
applyTheme(currentTheme.value as 'light' | 'dark')
}
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
systemTheme.value = e.matches ? 'dark' : 'light'
if (currentTheme.value === 'system') {
applyTheme(systemTheme.value)
}
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
// Return cleanup function
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
}
// Watch for theme changes
watch(appliedTheme, (newTheme) => {
applyTheme(newTheme)
})
return {
currentTheme: readonly(currentTheme),
appliedTheme,
systemTheme: readonly(systemTheme),
setTheme,
toggleTheme,
initializeTheme,
}
}
// Global theme state for use across components
let globalThemeCleanup: (() => void) | null = null
export function initializeGlobalTheme() {
if (globalThemeCleanup) {
globalThemeCleanup()
}
const { initializeTheme } = useTheme()
globalThemeCleanup = initializeTheme()
}
// Cleanup function for app unmount
export function cleanupGlobalTheme() {
if (globalThemeCleanup) {
globalThemeCleanup()
globalThemeCleanup = null
}
}

View File

@@ -7,9 +7,136 @@ import router from './router'
// Import Tailwind CSS
import './style.css'
// PrimeVue imports
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import DialogService from 'primevue/dialogservice'
// Import PrimeVue theme and icons
import 'primeicons/primeicons.css'
import ThrillWikiTheme from './theme/primevue-theme'
import { initializeGlobalTheme } from './composables/useTheme'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// Configure PrimeVue
app.use(PrimeVue, {
theme: {
preset: ThrillWikiTheme,
options: {
prefix: 'p',
darkModeSelector: '.dark',
cssLayer: false,
},
},
ripple: true,
inputStyle: 'outlined',
locale: {
startsWith: 'Starts with',
contains: 'Contains',
notContains: 'Not contains',
endsWith: 'Ends with',
equals: 'Equals',
notEquals: 'Not equals',
noFilter: 'No Filter',
lt: 'Less than',
lte: 'Less than or equal to',
gt: 'Greater than',
gte: 'Greater than or equal to',
dateIs: 'Date is',
dateIsNot: 'Date is not',
dateBefore: 'Date is before',
dateAfter: 'Date is after',
clear: 'Clear',
apply: 'Apply',
matchAll: 'Match All',
matchAny: 'Match Any',
addRule: 'Add Rule',
removeRule: 'Remove Rule',
accept: 'Yes',
reject: 'No',
choose: 'Choose',
upload: 'Upload',
cancel: 'Cancel',
completed: 'Completed',
pending: 'Pending',
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
dayNamesMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
monthNames: [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
monthNamesShort: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
chooseYear: 'Choose Year',
chooseMonth: 'Choose Month',
chooseDate: 'Choose Date',
prevDecade: 'Previous Decade',
nextDecade: 'Next Decade',
prevYear: 'Previous Year',
nextYear: 'Next Year',
prevMonth: 'Previous Month',
nextMonth: 'Next Month',
prevHour: 'Previous Hour',
nextHour: 'Next Hour',
prevMinute: 'Previous Minute',
nextMinute: 'Next Minute',
prevSecond: 'Previous Second',
nextSecond: 'Next Second',
am: 'AM',
pm: 'PM',
today: 'Today',
weekHeader: 'Wk',
firstDayOfWeek: 0,
showMonthAfterYear: false,
dateFormat: 'mm/dd/yy',
weak: 'Weak',
medium: 'Medium',
strong: 'Strong',
passwordPrompt: 'Enter a password',
emptyFilterMessage: 'No results found',
searchMessage: '{0} results are available',
selectionMessage: '{0} items selected',
emptySelectionMessage: 'No selected item',
emptySearchMessage: 'No results found',
emptyMessage: 'No available options',
},
})
// Add PrimeVue services
app.use(ToastService)
app.use(ConfirmationService)
app.use(DialogService)
// Initialize global theme
// Mark PrimeVue as installed so the composable detects it
initializeGlobalTheme()
app.mount('#app')

View File

@@ -77,10 +77,13 @@ export interface HistoryEvent {
ip_address?: string
}
changed_fields?: string[]
field_changes?: Record<string, {
old_value: any
new_value: any
}>
field_changes?: Record<
string,
{
old_value: any
new_value: any
}
>
}
export interface UnifiedHistoryEvent {
@@ -432,7 +435,10 @@ export class RidesApi {
* @param operation - The operation being performed
* @returns The appropriate base URL
*/
private getEndpointUrl(parkSlug?: string, operation: 'list' | 'detail' | 'search' = 'list'): string {
private getEndpointUrl(
parkSlug?: string,
operation: 'list' | 'detail' | 'search' = 'list',
): string {
if (parkSlug && (operation === 'list' || operation === 'detail')) {
// Use nested endpoint for park-contextual operations
return `/api/parks/${parkSlug}/rides`
@@ -461,18 +467,24 @@ export class RidesApi {
/**
* Get rides for a specific park - uses nested endpoint for park context
*/
async getRidesByPark(parkSlug: string, params?: {
page?: number
search?: string
ordering?: string
}): Promise<ApiResponse<Ride>> {
async getRidesByPark(
parkSlug: string,
params?: {
page?: number
search?: string
ordering?: string
},
): Promise<ApiResponse<Ride>> {
const queryParams: Record<string, string> = {}
if (params?.page) queryParams.page = params.page.toString()
if (params?.search) queryParams.search = params.search
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<Ride>>(`${this.getEndpointUrl(parkSlug, 'list')}/`, queryParams)
return this.client.get<ApiResponse<Ride>>(
`${this.getEndpointUrl(parkSlug, 'list')}/`,
queryParams,
)
}
/**
@@ -500,7 +512,10 @@ export class RidesApi {
* Search rides within a specific park - uses nested endpoint for park context
*/
async searchRidesInPark(parkSlug: string, query: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>(`${this.getEndpointUrl(parkSlug, 'search')}/search/`, { q: query })
return this.client.get<SearchResponse<Ride>>(
`${this.getEndpointUrl(parkSlug, 'search')}/search/`,
{ q: query },
)
}
/**
@@ -509,7 +524,7 @@ export class RidesApi {
async getRideHistory(
parkSlug: string,
rideSlug: string,
params?: HistoryParams
params?: HistoryParams,
): Promise<HistoryEvent[]> {
const historyApi = new HistoryApi(this.client)
return historyApi.getRideHistory(parkSlug, rideSlug, params)
@@ -618,7 +633,10 @@ export class RidesApi {
/**
* Search companies for manufacturer/designer autocomplete
*/
async searchCompanies(query: string, role?: 'manufacturer' | 'designer' | 'both'): Promise<any[]> {
async searchCompanies(
query: string,
role?: 'manufacturer' | 'designer' | 'both',
): Promise<any[]> {
const params: Record<string, string> = { q: query }
if (role) params.role = role
return this.client.get('/rides/search-companies/', params)
@@ -643,10 +661,10 @@ export class RidesApi {
*/
async getFilteredRides(filters: Record<string, any>): Promise<ApiResponse<Ride>> {
const params: Record<string, string> = {}
Object.entries(filters).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value)) {
if (value.length > 0) {
params[key] = value.join(',')
@@ -655,7 +673,7 @@ export class RidesApi {
params[key] = String(value)
}
})
return this.client.get<ApiResponse<Ride>>('/rides/', params)
}
}
@@ -805,7 +823,7 @@ export class HistoryApi {
async getRideHistory(
parkSlug: string,
rideSlug: string,
params?: HistoryParams
params?: HistoryParams,
): Promise<HistoryEvent[]> {
const queryParams: Record<string, string> = {}
@@ -817,7 +835,7 @@ export class HistoryApi {
return this.client.get<HistoryEvent[]>(
`/parks/${parkSlug}/rides/${rideSlug}/history/`,
queryParams
queryParams,
)
}
@@ -826,7 +844,7 @@ export class HistoryApi {
*/
async getRideHistoryDetail(parkSlug: string, rideSlug: string): Promise<RideHistoryResponse> {
return this.client.get<RideHistoryResponse>(
`/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
`/parks/${parkSlug}/rides/${rideSlug}/history/detail/`,
)
}
@@ -843,7 +861,7 @@ export class HistoryApi {
*/
async getModelHistory(
modelType: 'park' | 'ride' | 'company' | 'user',
params?: HistoryParams
params?: HistoryParams,
): Promise<UnifiedHistoryEvent[]> {
const timeline = await this.getUnifiedTimeline({
...params,
@@ -869,7 +887,7 @@ export class HistoryApi {
async getHistoryByDateRange(
startDate: string,
endDate: string,
params?: Omit<HistoryParams, 'start_date' | 'end_date'>
params?: Omit<HistoryParams, 'start_date' | 'end_date'>,
): Promise<UnifiedHistoryEvent[]> {
const timeline = await this.getUnifiedTimeline({
...params,
@@ -1041,7 +1059,7 @@ export class RankingsApi {
const response = await this.getRankings({
page_size: limit,
category: category as any,
ordering: 'rank'
ordering: 'rank',
})
return response.results
}
@@ -1049,15 +1067,18 @@ export class RankingsApi {
/**
* Get rankings for a specific park
*/
async getParkRankings(parkSlug: string, params?: {
page?: number
page_size?: number
category?: string
}): Promise<ApiResponse<RideRanking>> {
async getParkRankings(
parkSlug: string,
params?: {
page?: number
page_size?: number
category?: string
},
): Promise<ApiResponse<RideRanking>> {
return this.getRankings({
...params,
park: parkSlug,
category: params?.category as any
category: params?.category as any,
})
}
@@ -1068,8 +1089,8 @@ export class RankingsApi {
// This would ideally have a dedicated search endpoint, but for now
// we'll fetch all and filter client-side (not ideal for large datasets)
const response = await this.getRankings({ page_size: 100 })
return response.results.filter(ranking =>
ranking.ride.name.toLowerCase().includes(query.toLowerCase())
return response.results.filter((ranking) =>
ranking.ride.name.toLowerCase().includes(query.toLowerCase()),
)
}
@@ -1088,7 +1109,7 @@ export class RankingsApi {
current_rank: detail.rank,
previous_rank: detail.previous_rank,
change: Math.abs(change),
direction: change > 0 ? 'down' : change < 0 ? 'up' : 'same'
direction: change > 0 ? 'down' : change < 0 ? 'up' : 'same',
}
}
}
@@ -1106,12 +1127,15 @@ export class EntitySearchApi {
/**
* Perform fuzzy search for entities
*/
async fuzzySearch(query: string, params?: {
entity_types?: string[]
context_park?: string
limit?: number
min_confidence?: number
}): Promise<FuzzyMatchResult> {
async fuzzySearch(
query: string,
params?: {
entity_types?: string[]
context_park?: string
limit?: number
min_confidence?: number
},
): Promise<FuzzyMatchResult> {
const queryParams: Record<string, string> = { q: query }
if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',')
@@ -1125,10 +1149,14 @@ export class EntitySearchApi {
/**
* Handle entity not found scenarios with suggestions
*/
async handleNotFound(entityName: string, entityType?: string, context?: {
park_slug?: string
path?: string
}): Promise<EntityNotFoundResponse> {
async handleNotFound(
entityName: string,
entityType?: string,
context?: {
park_slug?: string
path?: string
},
): Promise<EntityNotFoundResponse> {
const data: Record<string, any> = { entity_name: entityName }
if (entityType) data.entity_type = entityType
@@ -1141,11 +1169,14 @@ export class EntitySearchApi {
/**
* Get quick suggestions for autocomplete
*/
async getQuickSuggestions(query: string, params?: {
entity_types?: string[]
limit?: number
park_context?: string
}): Promise<QuickSuggestionResponse> {
async getQuickSuggestions(
query: string,
params?: {
entity_types?: string[]
limit?: number
park_context?: string
},
): Promise<QuickSuggestionResponse> {
const queryParams: Record<string, string> = { q: query }
if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',')
@@ -1169,7 +1200,7 @@ export class EntitySearchApi {
const baseUrl = this.client['baseUrl']
return {
login: `${baseUrl}/auth/login/`,
signup: `${baseUrl}/auth/signup/`
signup: `${baseUrl}/auth/signup/`,
}
}
}
@@ -1257,5 +1288,5 @@ export type {
EntitySuggestion,
FuzzyMatchResult,
EntityNotFoundResponse,
QuickSuggestionResponse
QuickSuggestionResponse,
}

View File

@@ -4,13 +4,7 @@
import { defineStore } from 'pinia'
import { ref, computed, watch, watchEffect } from 'vue'
import type {
RideFilters,
FilterOptions,
ActiveFilter,
FilterFormState,
Ride
} from '@/types'
import type { RideFilters, FilterOptions, ActiveFilter, FilterFormState, Ride } from '@/types'
import { useRideFiltering } from '@/composables/useRideFiltering'
export const useRideFilteringStore = defineStore('rideFiltering', () => {
@@ -19,7 +13,7 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
const filterOptions = ref<FilterOptions | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// UI state
const formState = ref<FilterFormState>({
isOpen: false,
@@ -30,63 +24,66 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
specifications: false,
dates: false,
location: false,
advanced: false
advanced: false,
},
hasChanges: false,
appliedFilters: {},
pendingFilters: {}
pendingFilters: {},
})
// UI state for component compatibility
const uiState = ref({
sidebarVisible: false
sidebarVisible: false,
})
// Search state for component compatibility
const searchState = ref({
query: ''
query: '',
})
// Context state
const contextType = ref<'global' | 'park'>('global')
const contextValue = ref<string | null>(null)
// Results state
const rides = ref<Ride[]>([])
const totalCount = ref(0)
const currentPage = ref(1)
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Search state - original for internal use
const searchQuery = ref('')
const searchSuggestions = ref<any[]>([])
const showSuggestions = ref(false)
// Sync searchQuery with searchState.query for component compatibility
watchEffect(() => {
searchState.value.query = searchQuery.value
})
// Sync searchState.query back to searchQuery
watch(() => searchState.value.query, (newQuery) => {
if (newQuery !== searchQuery.value) {
searchQuery.value = newQuery
}
})
watch(
() => searchState.value.query,
(newQuery) => {
if (newQuery !== searchQuery.value) {
searchQuery.value = newQuery
}
},
)
// Filter presets
const savedPresets = ref<any[]>([])
const currentPreset = ref<string | null>(null)
// Computed properties
const hasActiveFilters = computed(() => {
return Object.values(filters.value).some(value => {
return Object.values(filters.value).some((value) => {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
})
})
const activeFiltersCount = computed(() => {
let count = 0
Object.entries(filters.value).forEach(([key, value]) => {
@@ -96,25 +93,25 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
})
return count
})
const activeFiltersList = computed((): ActiveFilter[] => {
const list: ActiveFilter[] = []
Object.entries(filters.value).forEach(([key, value]) => {
if (!value || key === 'page' || key === 'page_size') return
if (Array.isArray(value) && value.length > 0) {
list.push({
key,
label: getFilterLabel(key),
value: value.join(', '),
displayValue: value.join(', '),
category: 'select'
category: 'select',
})
} else if (value !== undefined && value !== null && value !== '') {
let displayValue = String(value)
let category: 'search' | 'select' | 'range' | 'date' = 'select'
if (key === 'search') {
category = 'search'
} else if (key.includes('_min') || key.includes('_max')) {
@@ -124,27 +121,27 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
category = 'date'
displayValue = formatDateValue(value)
}
list.push({
key,
label: getFilterLabel(key),
value,
displayValue,
category
category,
})
}
})
return list
})
const isFilterFormOpen = computed(() => formState.value.isOpen)
const hasUnsavedChanges = computed(() => formState.value.hasChanges)
// Component compatibility - allFilters computed property
const allFilters = computed(() => filters.value)
// Helper functions
const getFilterLabel = (key: string): string => {
const labels: Record<string, string> = {
@@ -172,11 +169,11 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
opening_date_to: 'Opened Before',
closing_date_from: 'Closed After',
closing_date_to: 'Closed Before',
ordering: 'Sort By'
ordering: 'Sort By',
}
return labels[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
return labels[key] || key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
}
const formatRangeValue = (key: string, value: any): string => {
const numValue = Number(value)
if (key.includes('height')) return `${numValue}m`
@@ -186,7 +183,7 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
if (key.includes('capacity')) return `${numValue} people`
return String(value)
}
const formatDateValue = (value: any): string => {
if (typeof value === 'string') {
const date = new Date(value)
@@ -194,136 +191,136 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
}
return String(value)
}
// Actions
const updateFilter = (key: keyof RideFilters, value: any) => {
filters.value = {
...filters.value,
[key]: value
[key]: value,
}
formState.value.hasChanges = true
}
const updateMultipleFilters = (newFilters: Partial<RideFilters>) => {
filters.value = {
...filters.value,
...newFilters
...newFilters,
}
formState.value.hasChanges = true
}
const clearFilter = (key: keyof RideFilters) => {
const newFilters = { ...filters.value }
delete newFilters[key]
filters.value = newFilters
formState.value.hasChanges = true
}
const clearAllFilters = () => {
const preserveKeys = ['page_size', 'ordering']
const newFilters: RideFilters = {}
preserveKeys.forEach(key => {
preserveKeys.forEach((key) => {
if (filters.value[key as keyof RideFilters]) {
newFilters[key as keyof RideFilters] = filters.value[key as keyof RideFilters]
}
})
filters.value = newFilters
currentPage.value = 1
formState.value.hasChanges = true
}
const applyFilters = () => {
formState.value.appliedFilters = { ...filters.value }
formState.value.hasChanges = false
currentPage.value = 1
}
const resetFilters = () => {
filters.value = { ...formState.value.appliedFilters }
formState.value.hasChanges = false
}
const toggleFilterForm = () => {
formState.value.isOpen = !formState.value.isOpen
}
const openFilterForm = () => {
formState.value.isOpen = true
}
const closeFilterForm = () => {
formState.value.isOpen = false
}
const toggleSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = !formState.value.expandedSections[sectionId]
}
const expandSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = true
}
const collapseSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = false
}
const expandAllSections = () => {
Object.keys(formState.value.expandedSections).forEach(key => {
Object.keys(formState.value.expandedSections).forEach((key) => {
formState.value.expandedSections[key] = true
})
}
const collapseAllSections = () => {
Object.keys(formState.value.expandedSections).forEach(key => {
Object.keys(formState.value.expandedSections).forEach((key) => {
formState.value.expandedSections[key] = false
})
}
const setSearchQuery = (query: string) => {
searchQuery.value = query
updateFilter('search', query)
}
const setSorting = (ordering: string) => {
updateFilter('ordering', ordering)
}
const setPageSize = (pageSize: number) => {
updateFilter('page_size', pageSize)
currentPage.value = 1
}
const goToPage = (page: number) => {
currentPage.value = page
updateFilter('page', page)
}
const nextPage = () => {
if (hasNextPage.value) {
goToPage(currentPage.value + 1)
}
}
const previousPage = () => {
if (hasPreviousPage.value) {
goToPage(currentPage.value - 1)
}
}
const setRides = (newRides: Ride[]) => {
rides.value = newRides
}
const appendRides = (newRides: Ride[]) => {
rides.value.push(...newRides)
}
const setTotalCount = (count: number) => {
totalCount.value = count
}
const setPagination = (pagination: {
hasNext: boolean
hasPrevious: boolean
@@ -333,65 +330,65 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
hasPreviousPage.value = pagination.hasPrevious
totalCount.value = pagination.totalCount
}
const setLoading = (loading: boolean) => {
isLoading.value = loading
}
const setError = (errorMessage: string | null) => {
error.value = errorMessage
}
const setFilterOptions = (options: FilterOptions) => {
filterOptions.value = options
}
const setSearchSuggestions = (suggestions: any[]) => {
searchSuggestions.value = suggestions
}
const showSearchSuggestions = () => {
showSuggestions.value = true
}
const hideSearchSuggestions = () => {
showSuggestions.value = false
}
// Preset management
const savePreset = (name: string) => {
const preset = {
id: Date.now().toString(),
name,
filters: { ...filters.value },
createdAt: new Date().toISOString()
createdAt: new Date().toISOString(),
}
savedPresets.value.push(preset)
currentPreset.value = preset.id
// Save to localStorage
localStorage.setItem('ride-filter-presets', JSON.stringify(savedPresets.value))
}
const loadPreset = (presetId: string) => {
const preset = savedPresets.value.find(p => p.id === presetId)
const preset = savedPresets.value.find((p) => p.id === presetId)
if (preset) {
filters.value = { ...preset.filters }
currentPreset.value = presetId
formState.value.hasChanges = true
}
}
const deletePreset = (presetId: string) => {
savedPresets.value = savedPresets.value.filter(p => p.id !== presetId)
savedPresets.value = savedPresets.value.filter((p) => p.id !== presetId)
if (currentPreset.value === presetId) {
currentPreset.value = null
}
// Update localStorage
localStorage.setItem('ride-filter-presets', JSON.stringify(savedPresets.value))
}
const loadPresetsFromStorage = () => {
try {
const stored = localStorage.getItem('ride-filter-presets')
@@ -402,31 +399,31 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
console.error('Failed to load filter presets:', error)
}
}
// Component compatibility methods
const toggleSidebar = () => {
uiState.value.sidebarVisible = !uiState.value.sidebarVisible
}
const clearSearchQuery = () => {
searchState.value.query = ''
searchQuery.value = ''
updateFilter('search', '')
}
const setContext = (type: 'global' | 'park', value?: string) => {
contextType.value = type
contextValue.value = value || null
// Reset filters when context changes
clearAllFilters()
console.log(`Context set to: ${type}${value ? ` (${value})` : ''}`)
}
// Initialize presets from localStorage
loadPresetsFromStorage()
return {
// State
filters,
@@ -444,11 +441,11 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
showSuggestions,
savedPresets,
currentPreset,
// Component compatibility state
uiState,
searchState,
// Computed
hasActiveFilters,
activeFiltersCount,
@@ -456,7 +453,7 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
isFilterFormOpen,
hasUnsavedChanges,
allFilters,
// Actions
updateFilter,
updateMultipleFilters,
@@ -492,10 +489,10 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
loadPreset,
deletePreset,
loadPresetsFromStorage,
// Component compatibility methods
toggleSidebar,
clearSearchQuery,
setContext
setContext,
}
})
})

View File

@@ -1,4 +1,7 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
/* Custom base styles */
@layer base {
@@ -36,3 +39,114 @@
text-wrap: balance;
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 1rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,211 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
// Custom ThrillWiki theme based on blue/purple gradient from hero section
const ThrillWikiTheme = definePreset(Aura, {
semantic: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', // Primary blue
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
colorScheme: {
light: {
primary: {
color: '#2563eb',
inverseColor: '#ffffff',
hoverColor: '#1d4ed8',
activeColor: '#1e40af',
},
highlight: {
background: '#dbeafe',
focusBackground: '#bfdbfe',
color: '#1e40af',
focusColor: '#1e3a8a',
},
surface: {
0: '#ffffff',
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
dark: {
primary: {
color: '#60a5fa',
inverseColor: '#1e293b',
hoverColor: '#3b82f6',
activeColor: '#2563eb',
},
highlight: {
background: '#1e40af',
focusBackground: '#1d4ed8',
color: '#dbeafe',
focusColor: '#f0f9ff',
},
surface: {
0: '#0f172a',
50: '#020617',
100: '#1e293b',
200: '#334155',
300: '#475569',
400: '#64748b',
500: '#94a3b8',
600: '#cbd5e1',
700: '#e2e8f0',
800: '#f1f5f9',
900: '#f8fafc',
950: '#ffffff',
},
},
},
},
components: {
button: {
root: {
borderRadius: '0.5rem',
paddingX: '1rem',
paddingY: '0.5rem',
gap: '0.5rem',
transitionDuration: '0.2s',
},
colorScheme: {
light: {
primary: {
background: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)',
hoverBackground: 'linear-gradient(135deg, #1d4ed8 0%, #6d28d9 100%)',
activeBackground: 'linear-gradient(135deg, #1e40af 0%, #5b21b6 100%)',
borderColor: 'transparent',
color: '#ffffff',
},
secondary: {
background: '#f1f5f9',
hoverBackground: '#e2e8f0',
activeBackground: '#cbd5e1',
borderColor: '#e2e8f0',
color: '#475569',
},
},
dark: {
primary: {
background: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
hoverBackground: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)',
activeBackground: 'linear-gradient(135deg, #1d4ed8 0%, #6d28d9 100%)',
borderColor: 'transparent',
color: '#ffffff',
},
secondary: {
background: '#334155',
hoverBackground: '#475569',
activeBackground: '#64748b',
borderColor: '#475569',
color: '#e2e8f0',
},
},
},
},
card: {
root: {
background: '{surface.0}',
borderRadius: '0.75rem',
color: '{surface.700}',
shadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
},
body: {
padding: '1.5rem',
},
title: {
fontSize: '1.25rem',
fontWeight: '600',
},
subtitle: {
color: '{surface.500}',
},
},
inputtext: {
root: {
background: '{surface.0}',
disabledBackground: '{surface.200}',
filledBackground: '{surface.50}',
filledHoverBackground: '{surface.50}',
filledFocusBackground: '{surface.50}',
borderColor: '{surface.300}',
hoverBorderColor: '{surface.400}',
focusBorderColor: '{primary.color}',
invalidBorderColor: '#ef4444',
color: '{surface.700}',
disabledColor: '{surface.500}',
placeholderColor: '{surface.500}',
shadow: 'none',
paddingX: '0.75rem',
paddingY: '0.5rem',
borderRadius: '0.5rem',
focusRing: {
width: '2px',
style: 'solid',
color: '{primary.color}',
offset: '2px',
shadow: 'none',
},
transitionDuration: '0.2s',
},
},
toast: {
root: {
width: '25rem',
borderRadius: '0.75rem',
borderWidth: '1px',
borderColor: '{surface.200}',
},
icon: {
size: '1.125rem',
},
content: {
padding: '1rem',
gap: '0.5rem',
},
text: {
gap: '0.5rem',
},
summary: {
fontWeight: '600',
fontSize: '0.875rem',
},
detail: {
fontWeight: '500',
fontSize: '0.75rem',
},
closeButton: {
width: '1.75rem',
height: '1.75rem',
borderRadius: '50%',
focusRing: {
width: '2px',
style: 'solid',
offset: '2px',
},
},
closeIcon: {
size: '1rem',
},
},
},
})
export default ThrillWikiTheme

164
frontend/src/types/declarations.d.ts vendored Normal file
View File

@@ -0,0 +1,164 @@
// Minimal ambient declarations to silence type errors during development.
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module '@/services/api' {
export const api: any
}
declare module '@/types' {
export interface Park {
id: number
name: string
slug: string
location?: string
description?: string
featured?: boolean
status?: string
average_rating?: number | null
ride_count?: number | null
coaster_count?: number | null
opening_date?: string | null
country?: string
}
export type { Park }
}
declare module '@/components/ui/card' {
export const Card: any
export const CardContent: any
export const CardHeader: any
export const CardTitle: any
export const CardDescription: any
export const CardFooter: any
}
declare module '@/components/ui/input' {
export const Input: any
}
declare module '@/components/ui/select' {
export const Select: any
export const SelectContent: any
export const SelectItem: any
export const SelectTrigger: any
export const SelectValue: any
}
declare module '@/components/ui/badge' {
export const Badge: any
}
declare module '@/components/ui/button' {
export const Button: any
}
declare module '@/components/ui/skeleton' {
export const Skeleton: any
}
declare module '@/components/ui/separator' {
export const Separator: any
}
declare module '@/components/ui/tabs' {
export const Tabs: any
export const TabsContent: any
export const TabsList: any
export const TabsTrigger: any
}
declare module '@/components/ui/progress' {
export const Progress: any
}
declare module '@/components/ui/tooltip' {
export const Tooltip: any
export const TooltipContent: any
export const TooltipProvider: any
export const TooltipTrigger: any
}
declare module '@/components/ui/hover-card' {
export const HoverCard: any
export const HoverCardContent: any
export const HoverCardTrigger: any
}
declare module '@/components/ui/avatar' {
export const Avatar: any
export const AvatarFallback: any
export const AvatarImage: any
}
declare module '@/components/ui/dialog' {
export const Dialog: any
export const DialogTrigger: any
export const DialogContent: any
export const DialogHeader: any
export const DialogTitle: any
export const DialogDescription: any
export const DialogFooter: any
}
declare module '@/components/ui/command' {
export const CommandDialog: any
export const CommandEmpty: any
export const CommandGroup: any
export const CommandInput: any
export const CommandItem: any
export const CommandList: any
export const CommandShortcut: any
}
declare module '@/components/ui/popover' {
export const Popover: any
export const PopoverContent: any
export const PopoverTrigger: any
}
declare module '@/components/ui/dropdown-menu' {
export const DropdownMenu: any
export const DropdownMenuCheckboxItem: any
export const DropdownMenuContent: any
export const DropdownMenuTrigger: any
}
declare module '@/components/ui/context-menu' {
export const ContextMenu: any
export const ContextMenuContent: any
export const ContextMenuItem: any
export const ContextMenuSeparator: any
export const ContextMenuTrigger: any
}
declare module '@/components/ui/alert-dialog' {
export const AlertDialog: any
export const AlertDialogAction: any
export const AlertDialogCancel: any
export const AlertDialogContent: any
export const AlertDialogDescription: any
export const AlertDialogFooter: any
export const AlertDialogHeader: any
export const AlertDialogTitle: any
}
declare module '@/components/ui/collapsible' {
export const Collapsible: any
export const CollapsibleContent: any
export const CollapsibleTrigger: any
}
declare module 'lucide-vue-next' {
export const Search: any
export const LayoutGrid: any
export const List: any
export const Building2: any
export const Settings: any
export const Sun: any
export const Moon: any
export const User: any
export const LogIn: any
export const UserPlus: any
const _default: any
export default _default
}

View File

@@ -9,12 +9,12 @@ export interface RideFilters {
category?: string | string[]
status?: string | string[]
park?: string | string[]
// Manufacturer and design filters
manufacturer?: string | string[]
designer?: string | string[]
manufacturer_role?: 'manufacturer' | 'designer' | 'both'
// Numeric range filters
height_min?: number
height_max?: number
@@ -28,20 +28,20 @@ export interface RideFilters {
duration_max?: number
inversions_min?: number
inversions_max?: number
// Date range filters
opening_date_from?: string
opening_date_to?: string
closing_date_from?: string
closing_date_to?: string
// Location filters
country?: string | string[]
region?: string | string[]
// Sorting
ordering?: string
// Pagination
page?: number
page_size?: number
@@ -169,4 +169,4 @@ export interface FilterStats {
filter: string
usage: number
}>
}
}

View File

@@ -10,13 +10,18 @@ export interface Park {
description: string
location: string
country: string
openingYear: number | null
opening_date: string | null
openingYear?: number | null // Legacy field for backward compatibility
status: 'open' | 'closed' | 'seasonal' | 'construction'
operator: string
propertyOwner?: string
website?: string
area?: number
rideCount: number
ride_count: number
coaster_count?: number
average_rating?: number | null
featured?: boolean
rideCount?: number // Legacy field for backward compatibility
created: string
updated: string
}
@@ -35,22 +40,41 @@ export interface Ride {
| 'family_ride'
| 'kiddie_ride'
| 'transport'
category_display?: string
status: 'operating' | 'closed' | 'under_construction' | 'seasonal' | 'sbno'
openingDate: string | null
closingDate: string | null
opening_date: string | null
closing_date: string | null
manufacturer?: string
manufacturer_name?: string
designer?: string
designer_name?: string
height_ft?: number
length_ft?: number
speed_mph?: number
inversions?: number
ride_duration_seconds?: number
capacity_per_hour?: number
park_id: number
park_name: string
park_slug: string
image_url?: string
average_rating?: number | null
review_count?: number
launch_type?: string
track_material?: string
created: string
updated: string
// Legacy fields for backward compatibility
openingDate?: string | null
closingDate?: string | null
height?: number
length?: number
speed?: number
inversions?: number
duration?: number
capacity?: number
parkId: number
parkName: string
parkSlug: string
created: string
updated: string
parkId?: number
parkName?: string
parkSlug?: string
}
// Search and filter types - Basic legacy interface (keeping for compatibility)
@@ -84,7 +108,7 @@ export type {
DateRangeConfig,
FilterValidation,
FilterPreset,
FilterStats
FilterStats,
} from './filters'
export interface ParkFilters {

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="bg-background min-h-screen">
<!-- Hero Section -->
<section
class="bg-gradient-to-br from-blue-600 via-purple-600 to-purple-700 text-white relative overflow-hidden"
@@ -8,16 +8,13 @@
<div class="text-center max-w-4xl mx-auto">
<h1 class="text-4xl md:text-6xl font-bold mb-6">Discover Your Next Thrill</h1>
<p class="text-xl md:text-2xl mb-8 text-purple-100">
Search through thousands of amusement rides and parks in an expansive
community database
Search through thousands of amusement rides and parks in an expansive community database
</p>
<!-- Search Bar -->
<div class="max-w-2xl mx-auto mb-16">
<div class="relative">
<div
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"
>
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg
class="h-6 w-6 text-gray-400"
fill="none"
@@ -32,19 +29,22 @@
/>
</svg>
</div>
<input
type="search"
placeholder="Search for amusement rides, parks, manufacturers..."
class="block w-full pl-12 pr-20 py-4 text-lg border-0 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50"
<PrimeInput
v-model="heroSearchQuery"
placeholder="Search for amusement rides, parks, manufacturers..."
icon-start="pi pi-search"
size="lg"
class="text-lg"
@keyup.enter="handleHeroSearch"
/>
<button
<PrimeButton
@click="handleHeroSearch"
variant="secondary"
size="large"
class="absolute inset-y-0 right-0 px-6 bg-gray-900 hover:bg-gray-800 rounded-r-lg text-white font-medium transition-colors"
>
Search
</button>
</PrimeButton>
</div>
</div>
@@ -76,31 +76,23 @@
</section>
<!-- What's Trending Section -->
<section class="py-16 bg-white dark:bg-gray-800">
<section class="py-16 bg-card">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<div class="flex items-center justify-center mb-4">
<svg
class="h-6 w-6 text-orange-500 mr-2"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2L13.09 8.26L22 9L13.09 9.74L12 16L10.91 9.74L2 9L10.91 8.26L12 2Z"
/>
<svg class="h-6 w-6 text-orange-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L13.09 8.26L22 9L13.09 9.74L12 16L10.91 9.74L2 9L10.91 8.26L12 2Z" />
</svg>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
What's Trending
</h2>
<h2 class="text-3xl md:text-4xl font-bold text-foreground">What's Trending</h2>
</div>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
<p class="text-xl text-muted-foreground max-w-2xl mx-auto">
See what the community is checking out this week
</p>
</div>
<!-- Trending Tabs -->
<div class="flex justify-center mb-8">
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<div class="flex bg-muted rounded-lg p-1 border-2 border-border shadow-md">
<button
v-for="tab in trendingTabs"
:key="tab.id"
@@ -108,8 +100,8 @@
:class="[
'px-6 py-2 text-sm font-medium rounded-md transition-colors',
activeTrendingTab === tab.id
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
? 'bg-card text-foreground shadow-lg border-2 border-border'
: 'text-muted-foreground hover:text-foreground hover:bg-card/50',
]"
>
{{ tab.label }}
@@ -123,15 +115,15 @@
<div
v-for="i in 3"
:key="'trending-skeleton-' + i"
class="bg-white dark:bg-gray-700 rounded-lg shadow-lg overflow-hidden animate-pulse"
class="bg-card border-2 border-border rounded-lg shadow-lg overflow-hidden animate-pulse"
>
<div class="h-48 bg-gray-200 dark:bg-gray-600"></div>
<div class="h-48 bg-muted"></div>
<div class="p-6">
<div class="h-6 bg-gray-200 dark:bg-gray-600 rounded mb-2"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded mb-3 w-2/3"></div>
<div class="h-6 bg-muted rounded mb-2"></div>
<div class="h-4 bg-muted rounded mb-3 w-2/3"></div>
<div class="flex justify-between">
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
<div class="h-4 bg-muted rounded w-1/4"></div>
<div class="h-4 bg-muted rounded w-1/4"></div>
</div>
</div>
</div>
@@ -139,11 +131,9 @@
<!-- Error State -->
<div v-else-if="trendingError" class="mb-8">
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center"
>
<div class="bg-destructive/10 border-2 border-destructive/20 rounded-lg p-6 text-center">
<svg
class="h-12 w-12 text-red-400 mx-auto mb-4"
class="h-12 w-12 text-destructive mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -155,16 +145,13 @@
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.314 15.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h3 class="text-lg font-medium text-red-800 dark:text-red-200 mb-2">
<h3 class="text-lg font-medium text-destructive mb-2">
Failed to Load Trending Content
</h3>
<p class="text-red-600 dark:text-red-400 mb-4">{{ trendingError }}</p>
<button
@click="fetchTrendingContent()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
<p class="text-destructive/80 mb-4">{{ trendingError }}</p>
<PrimeButton @click="fetchTrendingContent()" variant="destructive">
Try Again
</button>
</PrimeButton>
</div>
</div>
@@ -173,15 +160,13 @@
<article
v-for="item in getTrendingContent()"
:key="item.id"
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.01] hover:-translate-y-0.5"
class="bg-card border-2 border-border rounded-xl shadow-lg hover:shadow-xl overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.02] hover:-translate-y-1"
@click="viewTrendingItem(item)"
>
<!-- Image placeholder -->
<div
class="h-48 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center relative"
>
<div class="h-48 bg-muted flex items-center justify-center relative">
<svg
class="h-10 w-10 text-gray-400 dark:text-gray-500"
class="h-10 w-10 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -196,7 +181,7 @@
<!-- Trending badge -->
<div class="absolute top-4 left-4">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-orange-500 text-white shadow-sm"
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-orange-500 text-white shadow-lg border-2 border-orange-400"
>
#{{ item.rank }}
</span>
@@ -204,11 +189,10 @@
<!-- Rating badge -->
<div class="absolute top-4 right-4">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-black/75 text-white backdrop-blur-sm"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-card text-foreground shadow-lg border-2 border-border"
>
<svg
class="h-3 w-3 text-yellow-400 mr-1 flex-shrink-0"
fill="currentColor"
class="h-3 w-3 text-yellow-500 mr-1 flex-shrink-0 fill-current"
viewBox="0 0 20 20"
>
<path
@@ -220,19 +204,17 @@
</div>
</div>
<div class="p-6">
<div class="p-6 bg-card">
<h3
class="text-xl font-semibold text-gray-900 dark:text-white mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-tight"
class="text-xl font-semibold text-foreground mb-3 group-hover:text-primary transition-colors leading-tight"
>
{{ item.name }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">
<span class="font-medium text-gray-700 dark:text-gray-300">{{
item.location
}}</span>
<span v-if="item.category" class="text-gray-500 dark:text-gray-500">
{{ item.category }}</span
>
<p class="text-sm text-muted-foreground mb-4 leading-relaxed">
<span class="font-medium text-foreground">{{ item.location }}</span>
<span v-if="item.category" class="text-muted-foreground">
{{ item.category }}
</span>
</p>
<div class="flex items-center justify-between">
<span
@@ -254,7 +236,7 @@
+{{ item.views_change }}%
</span>
<span
class="text-sm font-medium text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
class="text-sm font-medium text-muted-foreground group-hover:text-primary transition-colors"
>
{{ item.views.toLocaleString() }} views
</span>
@@ -266,31 +248,23 @@
</section>
<!-- What's New Section -->
<section class="py-16 bg-gray-50 dark:bg-gray-900">
<section class="py-16 bg-muted">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<div class="flex items-center justify-center mb-4">
<svg
class="h-6 w-6 text-blue-500 mr-2"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2L15.09 8.26L22 9L15.09 9.74L12 16L8.91 9.74L2 9L8.91 8.26L12 2Z"
/>
<svg class="h-6 w-6 text-blue-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L15.09 8.26L22 9L15.09 9.74L12 16L8.91 9.74L2 9L8.91 8.26L12 2Z" />
</svg>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
What's New
</h2>
<h2 class="text-3xl md:text-4xl font-bold text-foreground">What's New</h2>
</div>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
<p class="text-xl text-muted-foreground max-w-2xl mx-auto">
Stay up to date with the latest additions and upcoming attractions
</p>
</div>
<!-- New Tabs -->
<div class="flex justify-center mb-8">
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<div class="flex bg-card rounded-lg p-1 border-2 border-border shadow-md">
<button
v-for="tab in newTabs"
:key="tab.id"
@@ -298,8 +272,8 @@
:class="[
'px-6 py-2 text-sm font-medium rounded-md transition-colors',
activeNewTab === tab.id
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
? 'bg-primary text-primary-foreground shadow-lg border-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
]"
>
{{ tab.label }}
@@ -313,15 +287,15 @@
<div
v-for="i in 2"
:key="'new-skeleton-' + i"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden animate-pulse"
class="bg-card border-2 border-border rounded-lg shadow-lg overflow-hidden animate-pulse"
>
<div class="h-48 bg-gray-200 dark:bg-gray-600"></div>
<div class="h-48 bg-muted"></div>
<div class="p-6">
<div class="h-6 bg-gray-200 dark:bg-gray-600 rounded mb-2"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded mb-3 w-2/3"></div>
<div class="h-6 bg-muted rounded mb-2"></div>
<div class="h-4 bg-muted rounded mb-3 w-2/3"></div>
<div class="flex justify-between">
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/3"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
<div class="h-4 bg-muted rounded w-1/3"></div>
<div class="h-4 bg-muted rounded w-1/4"></div>
</div>
</div>
</div>
@@ -329,11 +303,9 @@
<!-- Error State -->
<div v-else-if="newError" class="mb-8">
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center"
>
<div class="bg-destructive/10 border-2 border-destructive/20 rounded-lg p-6 text-center">
<svg
class="h-12 w-12 text-red-400 mx-auto mb-4"
class="h-12 w-12 text-destructive mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -345,16 +317,9 @@
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.314 15.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h3 class="text-lg font-medium text-red-800 dark:text-red-200 mb-2">
Failed to Load New Content
</h3>
<p class="text-red-600 dark:text-red-400 mb-4">{{ newError }}</p>
<button
@click="fetchNewContent()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Try Again
</button>
<h3 class="text-lg font-medium text-destructive mb-2">Failed to Load New Content</h3>
<p class="text-destructive/80 mb-4">{{ newError }}</p>
<PrimeButton @click="fetchNewContent()" variant="destructive"> Try Again </PrimeButton>
</div>
</div>
@@ -363,15 +328,13 @@
<article
v-for="item in getNewContent()"
:key="item.id"
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.01] hover:-translate-y-0.5"
class="bg-card border-2 border-border rounded-xl shadow-lg hover:shadow-xl overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.02] hover:-translate-y-1"
@click="viewNewItem(item)"
>
<!-- Image placeholder -->
<div
class="h-48 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center relative"
>
<div class="h-48 bg-muted flex items-center justify-center relative">
<svg
class="h-10 w-10 text-gray-400 dark:text-gray-500"
class="h-10 w-10 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -386,33 +349,31 @@
<!-- New badge -->
<div class="absolute top-4 left-4">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-500 text-white shadow-sm"
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-500 text-white shadow-lg border-2 border-green-400"
>
New
</span>
</div>
</div>
<div class="p-6">
<div class="p-6 bg-card">
<h3
class="text-xl font-semibold text-gray-900 dark:text-white mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-tight"
class="text-xl font-semibold text-foreground mb-3 group-hover:text-primary transition-colors leading-tight"
>
{{ item.name }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">
<span class="font-medium text-gray-700 dark:text-gray-300">{{
item.location
}}</span>
<span v-if="item.category" class="text-gray-500 dark:text-gray-500">
{{ item.category }}</span
>
<p class="text-sm text-muted-foreground mb-4 leading-relaxed">
<span class="font-medium text-foreground">{{ item.location }}</span>
<span v-if="item.category" class="text-muted-foreground">
{{ item.category }}
</span>
</p>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 font-medium">
<span class="text-sm text-muted-foreground font-medium">
Added {{ item.date_added }}
</span>
<span
class="text-sm font-medium text-blue-600 dark:text-blue-400 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors"
class="text-sm font-medium text-primary group-hover:text-primary/80 transition-colors"
>
Learn More
</span>
@@ -424,41 +385,43 @@
</section>
<!-- Join the ThrillWiki Community Section -->
<section class="py-16 bg-white dark:bg-gray-800">
<section class="py-16 bg-card">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-6">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-6">
Join the ThrillWiki Community
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-8">
Share your experiences, contribute to our database, and connect with fellow
theme park enthusiasts.
<p class="text-xl text-muted-foreground max-w-3xl mx-auto mb-8">
Share your experiences, contribute to our database, and connect with fellow theme park
enthusiasts.
</p>
<button
class="px-8 py-4 bg-gray-900 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 text-white font-medium rounded-lg transition-colors"
>
<PrimeButton variant="primary" size="large" class="px-8 py-4">
Get Started Today
</button>
</PrimeButton>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { trendingApi } from "@/services/api";
import type { TrendingItem, NewContentItem } from "@/types";
// Add a multi-word component name to satisfy the linter
defineOptions({ name: 'HomeView' })
const router = useRouter();
const heroSearchQuery = ref("");
const activeTrendingTab = ref("rides");
const activeNewTab = ref("recently-added");
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { trendingApi } from '@/services/api'
import type { TrendingItem, NewContentItem } from '@/types'
import { PrimeButton, PrimeInput } from '@/components/primevue'
const router = useRouter()
const heroSearchQuery = ref('')
const activeTrendingTab = ref('rides')
const activeNewTab = ref('recently-added')
// Loading states
const isLoadingTrending = ref(true);
const isLoadingNew = ref(true);
const trendingError = ref<string | null>(null);
const newError = ref<string | null>(null);
const isLoadingTrending = ref(true)
const isLoadingNew = ref(true)
const trendingError = ref<string | null>(null)
const newError = ref<string | null>(null)
// Sample data matching the design
const stats = ref({
@@ -466,282 +429,282 @@ const stats = ref({
rides: 10,
reviews: 20,
photos: 1,
});
})
const trendingTabs = [
{ id: "rides", label: "Trending Rides" },
{ id: "parks", label: "Trending Parks" },
{ id: "reviews", label: "Latest Reviews" },
];
{ id: 'rides', label: 'Trending Rides' },
{ id: 'parks', label: 'Trending Parks' },
{ id: 'reviews', label: 'Latest Reviews' },
]
const newTabs = [
{ id: "recently-added", label: "Recently Added" },
{ id: "newly-opened", label: "Newly Opened" },
{ id: "upcoming", label: "Upcoming" },
];
{ id: 'recently-added', label: 'Recently Added' },
{ id: 'newly-opened', label: 'Newly Opened' },
{ id: 'upcoming', label: 'Upcoming' },
]
// Data from API
const trendingRides = ref<TrendingItem[]>([]);
const trendingParks = ref<TrendingItem[]>([]);
const latestReviews = ref<TrendingItem[]>([]);
const recentlyAdded = ref<NewContentItem[]>([]);
const newlyOpened = ref<NewContentItem[]>([]);
const upcoming = ref<NewContentItem[]>([]);
const trendingRides = ref<TrendingItem[]>([])
const trendingParks = ref<TrendingItem[]>([])
const latestReviews = ref<TrendingItem[]>([])
const recentlyAdded = ref<NewContentItem[]>([])
const newlyOpened = ref<NewContentItem[]>([])
const upcoming = ref<NewContentItem[]>([])
// Methods
const handleHeroSearch = () => {
if (heroSearchQuery.value.trim()) {
router.push({
name: "search-results",
name: 'search-results',
query: { q: heroSearchQuery.value.trim() },
});
})
}
};
}
const getTrendingContent = () => {
switch (activeTrendingTab.value) {
case "rides":
return trendingRides.value;
case "parks":
return trendingParks.value;
case "reviews":
return latestReviews.value;
case 'rides':
return trendingRides.value
case 'parks':
return trendingParks.value
case 'reviews':
return latestReviews.value
default:
return trendingRides.value;
return trendingRides.value
}
};
}
const getNewContent = () => {
switch (activeNewTab.value) {
case "recently-added":
return recentlyAdded.value;
case "newly-opened":
return newlyOpened.value;
case "upcoming":
return upcoming.value;
case 'recently-added':
return recentlyAdded.value
case 'newly-opened':
return newlyOpened.value
case 'upcoming':
return upcoming.value
default:
return recentlyAdded.value;
return recentlyAdded.value
}
};
}
const viewTrendingItem = (item: any) => {
if (activeTrendingTab.value === "parks") {
router.push({ name: "park-detail", params: { slug: item.slug } });
} else if (activeTrendingTab.value === "rides") {
router.push({ name: "global-ride-detail", params: { rideSlug: item.slug } });
if (activeTrendingTab.value === 'parks') {
router.push({ name: 'park-detail', params: { slug: item.slug } })
} else if (activeTrendingTab.value === 'rides') {
router.push({ name: 'global-ride-detail', params: { rideSlug: item.slug } })
}
};
}
const viewNewItem = (item: any) => {
router.push({ name: "park-detail", params: { slug: item.slug } });
};
router.push({ name: 'park-detail', params: { slug: item.slug } })
}
// API calls
const fetchTrendingContent = async () => {
try {
isLoadingTrending.value = true;
trendingError.value = null;
isLoadingTrending.value = true
trendingError.value = null
const response = await trendingApi.getTrendingContent();
trendingRides.value = response.trending_rides;
trendingParks.value = response.trending_parks;
latestReviews.value = response.latest_reviews;
const response = await trendingApi.getTrendingContent()
trendingRides.value = response.trending_rides
trendingParks.value = response.trending_parks
latestReviews.value = response.latest_reviews
} catch (error) {
console.error("Error fetching trending content:", error);
trendingError.value = "Failed to load trending content";
console.error('Error fetching trending content:', error)
trendingError.value = 'Failed to load trending content'
// Fallback to sample data on error
trendingRides.value = [
{
id: 1,
name: "Steel Vengeance",
location: "Cedar Point",
category: "Hybrid Coaster",
name: 'Steel Vengeance',
location: 'Cedar Point',
category: 'Hybrid Coaster',
rating: 4.9,
rank: 1,
views: 4820,
views_change: 23,
slug: "steel-vengeance",
slug: 'steel-vengeance',
},
{
id: 2,
name: "Kingda Ka",
location: "Six Flags Great Adventure",
category: "Launched Coaster",
name: 'Kingda Ka',
location: 'Six Flags Great Adventure',
category: 'Launched Coaster',
rating: 4.8,
rank: 2,
views: 3647,
views_change: 18,
slug: "kingda-ka",
slug: 'kingda-ka',
},
{
id: 3,
name: "Pirates of the Caribbean",
location: "Disneyland",
category: "Dark Ride",
name: 'Pirates of the Caribbean',
location: 'Disneyland',
category: 'Dark Ride',
rating: 4.7,
rank: 3,
views: 3156,
views_change: 12,
slug: "pirates-of-the-caribbean",
slug: 'pirates-of-the-caribbean',
},
];
]
trendingParks.value = [
{
id: 1,
name: "Cedar Point",
location: "Sandusky, Ohio",
category: "Amusement Park",
name: 'Cedar Point',
location: 'Sandusky, Ohio',
category: 'Amusement Park',
rating: 4.8,
rank: 1,
views: 8920,
views_change: 15,
slug: "cedar-point",
slug: 'cedar-point',
},
{
id: 2,
name: "Magic Kingdom",
location: "Orlando, Florida",
category: "Theme Park",
name: 'Magic Kingdom',
location: 'Orlando, Florida',
category: 'Theme Park',
rating: 4.9,
rank: 2,
views: 7654,
views_change: 12,
slug: "magic-kingdom",
slug: 'magic-kingdom',
},
{
id: 3,
name: "Europa-Park",
location: "Rust, Germany",
category: "Theme Park",
name: 'Europa-Park',
location: 'Rust, Germany',
category: 'Theme Park',
rating: 4.7,
rank: 3,
views: 5432,
views_change: 22,
slug: "europa-park",
slug: 'europa-park',
},
];
]
latestReviews.value = [
{
id: 1,
name: "Steel Vengeance Review",
location: "Cedar Point",
category: "Roller Coaster",
name: 'Steel Vengeance Review',
location: 'Cedar Point',
category: 'Roller Coaster',
rating: 5.0,
rank: 1,
views: 1234,
views_change: 45,
slug: "steel-vengeance-review",
slug: 'steel-vengeance-review',
},
{
id: 2,
name: "Kingda Ka Experience",
location: "Six Flags Great Adventure",
category: "Launch Coaster",
name: 'Kingda Ka Experience',
location: 'Six Flags Great Adventure',
category: 'Launch Coaster',
rating: 4.8,
rank: 2,
views: 987,
views_change: 32,
slug: "kingda-ka-review",
slug: 'kingda-ka-review',
},
{
id: 3,
name: "Pirates Ride Review",
location: "Disneyland",
category: "Dark Ride",
name: 'Pirates Ride Review',
location: 'Disneyland',
category: 'Dark Ride',
rating: 4.6,
rank: 3,
views: 765,
views_change: 28,
slug: "pirates-review",
slug: 'pirates-review',
},
];
]
} finally {
isLoadingTrending.value = false;
isLoadingTrending.value = false
}
};
}
const fetchNewContent = async () => {
try {
isLoadingNew.value = true;
newError.value = null;
isLoadingNew.value = true
newError.value = null
const response = await trendingApi.getNewContent();
recentlyAdded.value = response.recently_added;
newlyOpened.value = response.newly_opened;
upcoming.value = response.upcoming;
const response = await trendingApi.getNewContent()
recentlyAdded.value = response.recently_added
newlyOpened.value = response.newly_opened
upcoming.value = response.upcoming
} catch (error) {
console.error("Error fetching new content:", error);
newError.value = "Failed to load new content";
console.error('Error fetching new content:', error)
newError.value = 'Failed to load new content'
// Fallback to sample data on error
recentlyAdded.value = [
{
id: 1,
name: "Guardians of the Galaxy: Cosmic Rewind",
location: "EPCOT",
category: "Indoor Coaster",
date_added: "2024-01-20",
slug: "guardians-cosmic-rewind",
name: 'Guardians of the Galaxy: Cosmic Rewind',
location: 'EPCOT',
category: 'Indoor Coaster',
date_added: '2024-01-20',
slug: 'guardians-cosmic-rewind',
},
{
id: 2,
name: "VelociCoaster",
name: 'VelociCoaster',
location: "Universal's Islands of Adventure",
category: "Launch Coaster",
date_added: "2024-01-18",
slug: "velocicoaster",
category: 'Launch Coaster',
date_added: '2024-01-18',
slug: 'velocicoaster',
},
];
]
newlyOpened.value = [
{
id: 1,
name: "TRON Lightcycle / Run",
location: "Magic Kingdom",
category: "Launch Coaster",
date_added: "2023-04-04",
slug: "tron-lightcycle-run",
name: 'TRON Lightcycle / Run',
location: 'Magic Kingdom',
category: 'Launch Coaster',
date_added: '2023-04-04',
slug: 'tron-lightcycle-run',
},
{
id: 2,
name: "Hagrid's Magical Creatures Motorbike Adventure",
location: "Universal's Islands of Adventure",
category: "Story Coaster",
date_added: "2019-06-13",
slug: "hagrids-motorbike-adventure",
category: 'Story Coaster',
date_added: '2019-06-13',
slug: 'hagrids-motorbike-adventure',
},
];
]
upcoming.value = [
{
id: 1,
name: "Epic Universe",
location: "Universal Orlando",
category: "Theme Park",
date_added: "Opening 2025",
slug: "epic-universe",
name: 'Epic Universe',
location: 'Universal Orlando',
category: 'Theme Park',
date_added: 'Opening 2025',
slug: 'epic-universe',
},
{
id: 2,
name: "New Fantasyland Expansion",
location: "Magic Kingdom",
category: "Land Expansion",
date_added: "Opening 2026",
slug: "fantasyland-expansion",
name: 'New Fantasyland Expansion',
location: 'Magic Kingdom',
category: 'Land Expansion',
date_added: 'Opening 2026',
slug: 'fantasyland-expansion',
},
];
]
} finally {
isLoadingNew.value = false;
isLoadingNew.value = false
}
};
}
onMounted(async () => {
console.log("Home view mounted - fetching trending data from API");
console.log('Home view mounted - fetching trending data from API')
// Fetch both trending and new content in parallel
await Promise.all([fetchTrendingContent(), fetchNewContent()]);
});
await Promise.all([fetchTrendingContent(), fetchNewContent()])
})
</script>

View File

@@ -1,45 +1,39 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<!-- Header -->
<div class="min-h-screen bg-gradient-to-br from-surface-50 via-surface-0 to-primary-50 dark:from-surface-950 dark:via-surface-900 dark:to-surface-800 transition-all duration-300">
<!-- Enhanced Header with Gradient -->
<header
class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40"
class="bg-surface-0/80 dark:bg-surface-900/80 backdrop-blur-lg border-b border-primary-200/20 dark:border-primary-800/20 sticky top-0 z-40 shadow-lg shadow-primary-500/10"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Left side - Title and breadcrumb -->
<!-- Left side - Enhanced breadcrumb -->
<div class="flex items-center space-x-4">
<nav class="flex" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li>
<router-link
to="/"
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors duration-200 p-1.5 rounded-lg hover:bg-primary-50 dark:hover:bg-primary-950/50"
>
<Icon name="home" class="w-5 h-5" />
<i class="pi pi-home w-5 h-5" />
</router-link>
</li>
<li>
<Icon
name="chevron-right"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
<i class="pi pi-chevron-right w-4 h-4 text-surface-400 dark:text-surface-500" />
</li>
<li v-if="parkSlug">
<router-link
:to="`/parks/${parkSlug}`"
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
class="text-surface-600 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 px-2 py-1 rounded-md hover:bg-primary-50 dark:hover:bg-primary-950/30"
>
{{ parkName || "Park" }}
{{ parkName || 'Park' }}
</router-link>
</li>
<li v-if="parkSlug">
<Icon
name="chevron-right"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
<i class="pi pi-chevron-right w-4 h-4 text-surface-400 dark:text-surface-500" />
</li>
<li>
<span class="text-gray-900 dark:text-gray-100 font-medium">
<span class="text-surface-900 dark:text-surface-100 font-semibold px-2 py-1 bg-gradient-to-r from-primary-500 to-purple-600 bg-clip-text text-transparent">
{{ pageTitle }}
</span>
</li>
@@ -47,62 +41,69 @@
</nav>
</div>
<!-- Right side - Actions -->
<div class="flex items-center space-x-4">
<!-- Filter toggle for mobile -->
<button
<!-- Right side - Enhanced Actions -->
<div class="flex items-center space-x-3">
<!-- Enhanced Filter toggle for mobile -->
<Button
@click="toggleMobileFilters"
class="md:hidden inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
severity="secondary"
class="md:hidden relative"
>
<Icon name="filter" class="w-4 h-4 mr-2" />
<i class="pi pi-filter w-4 h-4 mr-2" />
Filters
<span
<Badge
v-if="activeFilterCount > 0"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200"
>
{{ activeFilterCount }}
</span>
</button>
:value="activeFilterCount"
severity="info"
class="ml-2"
/>
</Button>
<!-- Theme toggle -->
<button
<!-- Enhanced Theme toggle -->
<Button
@click="toggleTheme"
class="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
severity="secondary"
class="p-2"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
>
<Icon :name="isDark ? 'sun' : 'moon'" class="w-5 h-5" />
</button>
<i :class="[
'w-5 h-5',
isDark ? 'pi pi-sun' : 'pi pi-moon'
]" />
</Button>
</div>
</div>
</div>
</header>
<!-- Main content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Enhanced Main content with beautiful gradients -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="lg:grid lg:grid-cols-5 lg:gap-8">
<!-- Filter sidebar -->
<!-- Enhanced Filter sidebar -->
<aside class="lg:col-span-1">
<!-- Mobile filter overlay -->
<!-- Enhanced Mobile filter overlay -->
<div
v-if="showMobileFilters"
class="fixed inset-0 z-50 lg:hidden"
@click="closeMobileFilters"
>
<div class="fixed inset-0 bg-black bg-opacity-50" />
<div class="fixed inset-0 bg-surface-900/80 backdrop-blur-sm" />
<div
class="fixed inset-y-0 left-0 w-80 bg-white dark:bg-gray-800 shadow-xl overflow-y-auto"
class="fixed inset-y-0 left-0 w-80 bg-gradient-to-b from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-800 shadow-2xl overflow-y-auto border-r border-primary-200/30 dark:border-primary-800/30"
>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-primary-200/30 dark:border-primary-800/30 bg-gradient-to-r from-primary-500/10 to-purple-600/10">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
<h2 class="text-lg font-bold bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent">
Filters
</h2>
<button
<Button
@click="closeMobileFilters"
class="p-2 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
severity="secondary"
size="small"
class="p-2"
>
<Icon name="x" class="w-5 h-5" />
</button>
<i class="pi pi-times w-4 h-4" />
</Button>
</div>
</div>
<div class="p-4">
@@ -111,110 +112,128 @@
</div>
</div>
<!-- Desktop filter sidebar -->
<!-- Enhanced Desktop filter sidebar -->
<div class="hidden lg:block">
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 sticky top-24"
<Card
class="sticky top-24 bg-gradient-to-br from-surface-0 via-surface-0 to-primary-50/30 dark:from-surface-900 dark:via-surface-900 dark:to-primary-950/30 backdrop-blur-sm border-primary-200/30 dark:border-primary-800/30 shadow-xl shadow-primary-500/10"
>
<div class="p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-6">
Filter Rides
</h2>
<RideFilterSidebar :park-slug="parkSlug" />
</div>
</div>
<template #content>
<div class="p-6">
<h2 class="text-xl font-bold mb-6 bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent">
Filter Rides
</h2>
<RideFilterSidebar :park-slug="parkSlug" />
</div>
</template>
</Card>
</div>
</aside>
<!-- Main content area -->
<!-- Enhanced Main content area -->
<main class="lg:col-span-4">
<!-- Page description -->
<div class="mb-6">
<h1
class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2"
>
<!-- Enhanced Page description -->
<div class="mb-8">
<h1 class="text-3xl sm:text-4xl font-bold mb-3 bg-gradient-to-r from-primary-600 via-primary-700 to-purple-600 bg-clip-text text-transparent">
{{ pageTitle }}
</h1>
<p class="text-gray-600 dark:text-gray-400 text-lg">
<p class="text-surface-700 dark:text-surface-300 text-lg leading-relaxed">
{{ pageDescription }}
</p>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
<!-- Enhanced Quick stats with gradients -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<Card
class="group hover:scale-105 transition-all duration-300 bg-gradient-to-br from-primary-500 to-primary-600 border-0 shadow-lg shadow-primary-500/25"
>
<div class="flex items-center">
<Icon name="map-pin" class="w-8 h-8 text-blue-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Total Rides
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalCount || 0 }}
</p>
<template #content>
<div class="p-4">
<div class="flex items-center">
<div class="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
<i class="pi pi-map-marker w-6 h-6 text-white" />
</div>
<div class="ml-4">
<p class="text-sm font-medium text-white/90">Total Rides</p>
<p class="text-2xl font-bold text-white">
{{ totalCount || 0 }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
<Card
class="group hover:scale-105 transition-all duration-300 bg-gradient-to-br from-green-500 to-emerald-600 border-0 shadow-lg shadow-green-500/25"
>
<div class="flex items-center">
<Icon name="filter" class="w-8 h-8 text-green-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Active Filters
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ activeFilterCount }}
</p>
<template #content>
<div class="p-4">
<div class="flex items-center">
<div class="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
<i class="pi pi-filter w-6 h-6 text-white" />
</div>
<div class="ml-4">
<p class="text-sm font-medium text-white/90">Active Filters</p>
<p class="text-2xl font-bold text-white">
{{ activeFilterCount }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
<Card
class="group hover:scale-105 transition-all duration-300 bg-gradient-to-br from-yellow-500 to-orange-500 border-0 shadow-lg shadow-yellow-500/25"
>
<div class="flex items-center">
<Icon name="star" class="w-8 h-8 text-yellow-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Avg Rating
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ averageRating ? averageRating.toFixed(1) : "--" }}
</p>
<template #content>
<div class="p-4">
<div class="flex items-center">
<div class="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
<i class="pi pi-star w-6 h-6 text-white" />
</div>
<div class="ml-4">
<p class="text-sm font-medium text-white/90">Avg Rating</p>
<p class="text-2xl font-bold text-white">
{{ averageRating ? averageRating.toFixed(1) : '--' }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
<Card
class="group hover:scale-105 transition-all duration-300 bg-gradient-to-br from-purple-500 to-pink-600 border-0 shadow-lg shadow-purple-500/25"
>
<div class="flex items-center">
<Icon name="zap" class="w-8 h-8 text-purple-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Operating
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ operatingCount || 0 }}
</p>
<template #content>
<div class="p-4">
<div class="flex items-center">
<div class="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
<i class="pi pi-bolt w-6 h-6 text-white" />
</div>
<div class="ml-4">
<p class="text-sm font-medium text-white/90">Operating</p>
<p class="text-2xl font-bold text-white">
{{ operatingCount || 0 }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Ride list -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
<!-- Enhanced Ride list -->
<Card
class="bg-gradient-to-br from-surface-0 via-surface-0 to-primary-50/20 dark:from-surface-900 dark:via-surface-900 dark:to-primary-950/20 backdrop-blur-sm border-primary-200/30 dark:border-primary-800/30 shadow-xl shadow-primary-500/10"
>
<div class="p-6">
<RideListDisplay :park-slug="parkSlug" />
</div>
</div>
<template #content>
<div class="p-6">
<RideListDisplay :park-slug="parkSlug" />
</div>
</template>
</Card>
</main>
</div>
</div>
@@ -222,116 +241,118 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watchEffect } from "vue";
import { useRoute } from "vue-router";
import { storeToRefs } from "pinia";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { useTheme } from "@/composables/useTheme";
import RideFilterSidebar from "@/components/filters/RideFilterSidebar.vue";
import RideListDisplay from "@/components/rides/RideListDisplay.vue";
import Icon from "@/components/ui/Icon.vue";
import { computed, ref, onMounted, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useRideFilteringStore } from '@/stores/rideFiltering'
import { useTheme } from '@/composables/useTheme'
import RideFilterSidebar from '@/components/filters/RideFilterSidebar.vue'
import RideListDisplay from '@/components/rides/RideListDisplay.vue'
import Button from 'primevue/button'
import Badge from 'primevue/badge'
import Card from 'primevue/card'
// Props
interface Props {
parkSlug?: string;
parkName?: string;
parkSlug?: string
parkName?: string
}
const props = withDefaults(defineProps<Props>(), {
parkSlug: undefined,
parkName: undefined,
});
})
// Composables
const route = useRoute();
const rideFilteringStore = useRideFilteringStore();
const { isDark, toggleTheme } = useTheme();
const route = useRoute()
const rideFilteringStore = useRideFilteringStore()
const { isDark, toggleTheme } = useTheme()
// Store state
const { totalCount, rides, filters } = storeToRefs(rideFilteringStore);
const { totalCount, rides, filters } = storeToRefs(rideFilteringStore)
// Reactive state
const showMobileFilters = ref(false);
const showMobileFilters = ref(false)
// Computed properties
const pageTitle = computed(() => {
if (props.parkSlug) {
return `${props.parkName || "Park"} Rides`;
return `${props.parkName || 'Park'} Rides`
}
return "All Rides";
});
return 'All Rides'
})
const pageDescription = computed(() => {
if (props.parkSlug) {
return `Discover and explore all the exciting rides at ${
props.parkName || "this park"
}. Use the filters to find exactly what you're looking for.`;
props.parkName || 'this park'
}. Use the filters to find exactly what you're looking for.`
}
return "Discover and explore amazing rides from theme parks around the world. Use the advanced filters to find exactly what you're looking for.";
});
return "Discover and explore amazing rides from theme parks around the world. Use the advanced filters to find exactly what you're looking for."
})
const activeFilterCount = computed(() => {
const f = filters.value;
let count = 0;
const f = filters.value
let count = 0
if (f.search) count++;
if (f.categories.length > 0) count++;
if (f.manufacturers.length > 0) count++;
if (f.designers.length > 0) count++;
if (f.parks.length > 0) count++;
if (f.status.length > 0) count++;
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++;
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++;
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++;
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++;
if (f.openingDateRange[0] || f.openingDateRange[1]) count++;
if (f.closingDateRange[0] || f.closingDateRange[1]) count++;
if (f.search) count++
if (f.categories.length > 0) count++
if (f.manufacturers.length > 0) count++
if (f.designers.length > 0) count++
if (f.parks.length > 0) count++
if (f.status.length > 0) count++
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++
if (f.openingDateRange[0] || f.openingDateRange[1]) count++
if (f.closingDateRange[0] || f.closingDateRange[1]) count++
return count;
});
return count
})
const averageRating = computed(() => {
if (!rides.value.length) return null;
if (!rides.value.length) return null
const ridesWithRatings = rides.value.filter((ride) => ride.average_rating);
if (!ridesWithRatings.length) return null;
const ridesWithRatings = rides.value.filter((ride) => ride.average_rating)
if (!ridesWithRatings.length) return null
const sum = ridesWithRatings.reduce((acc, ride) => acc + (ride.average_rating || 0), 0);
return sum / ridesWithRatings.length;
});
const sum = ridesWithRatings.reduce((acc, ride) => acc + (ride.average_rating || 0), 0)
return sum / ridesWithRatings.length
})
const operatingCount = computed(() => {
return rides.value.filter((ride) => ride.status?.toLowerCase() === "operating").length;
});
return rides.value.filter((ride) => ride.status?.toLowerCase() === 'operating').length
})
// Methods
const toggleMobileFilters = () => {
showMobileFilters.value = !showMobileFilters.value;
};
showMobileFilters.value = !showMobileFilters.value
}
const closeMobileFilters = () => {
showMobileFilters.value = false;
};
showMobileFilters.value = false
}
// Handle route changes
watchEffect(() => {
// Update park context when route changes
if (route.params.parkSlug !== props.parkSlug) {
// Reset filters when switching between park-specific and global views
rideFilteringStore.resetFilters();
rideFilteringStore.resetFilters()
}
});
})
// Initialize the page
onMounted(() => {
// Close mobile filters when clicking outside
document.addEventListener("click", (event) => {
const target = event.target as Element;
if (showMobileFilters.value && !target.closest(".mobile-filter-sidebar")) {
closeMobileFilters();
document.addEventListener('click', (event) => {
const target = event.target as Element
if (showMobileFilters.value && !target.closest('.mobile-filter-sidebar')) {
closeMobileFilters()
}
});
});
})
})
</script>
<style scoped>
@@ -341,29 +362,29 @@ onMounted(() => {
}
.mobile-filter-sidebar::-webkit-scrollbar-track {
background: theme("colors.gray.100");
background: theme('colors.gray.100');
}
.mobile-filter-sidebar::-webkit-scrollbar-thumb {
background: theme("colors.gray.400");
background: theme('colors.gray.400');
border-radius: 3px;
}
.mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
background: theme("colors.gray.500");
background: theme('colors.gray.500');
}
/* Dark mode scrollbar */
.dark .mobile-filter-sidebar::-webkit-scrollbar-track {
background: theme("colors.gray.700");
background: theme('colors.gray.700');
}
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb {
background: theme("colors.gray.500");
background: theme('colors.gray.500');
}
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
background: theme("colors.gray.400");
background: theme('colors.gray.400');
}
/* Smooth transitions */

View File

@@ -56,7 +56,11 @@
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
></path>
</svg>
{{ park.average_rating.toFixed(1) }}/10
{{
park.average_rating && typeof park.average_rating === 'number'
? park.average_rating.toFixed(1)
: 'N/A'
}}/10
</span>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,340 +1,359 @@
<template>
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="flex">
<!-- Filter Sidebar -->
<RideFilterSidebar
v-if="filterStore.uiState.sidebarVisible"
class="w-80 flex-shrink-0 h-screen sticky top-0 overflow-y-auto"
/>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight text-surface-900 dark:text-surface-50">
{{ parkSlug ? `${parkName} Rides` : 'Discover Rides' }}
</h1>
<p class="text-surface-600 dark:text-surface-400">
{{
parkSlug
? `Explore all the thrilling rides and attractions at ${parkName}`
: 'Find amazing rides and attractions from theme parks around the world'
}}
</p>
</div>
<!-- Main Content -->
<div class="flex-1 min-w-0">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="text-center flex-1">
<h1
class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-4"
>
{{ parkSlug ? `${parkName} Rides` : "All Rides" }}
</h1>
<p
v-if="parkSlug"
class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"
>
Explore all the thrilling rides and attractions at {{ parkName }}.
</p>
<p
v-else
class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"
>
Discover amazing rides and attractions from theme parks around the world.
</p>
</div>
<!-- View Controls -->
<div class="flex items-center gap-2">
<Button
:severity="viewMode === 'grid' ? 'primary' : 'secondary'"
:outlined="viewMode !== 'grid'"
size="small"
@click="setViewMode('grid')"
>
<i class="pi pi-th-large"></i>
</Button>
<Button
:severity="viewMode === 'list' ? 'primary' : 'secondary'"
:outlined="viewMode !== 'list'"
size="small"
@click="setViewMode('list')"
>
<i class="pi pi-list"></i>
</Button>
</div>
</div>
<!-- Toggle Filter Sidebar Button -->
<button
@click="filterStore.toggleSidebar()"
class="ml-4 p-2 rounded-lg bg-white dark:bg-gray-800 shadow-lg hover:shadow-xl transition-shadow border border-gray-200 dark:border-gray-700"
:class="{
'text-blue-600 dark:text-blue-400': filterStore.uiState.sidebarVisible,
}"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
></path>
</svg>
</button>
</div>
<!-- Breadcrumb for park-specific rides -->
<nav v-if="parkSlug" class="mb-6" aria-label="Breadcrumb">
<ol
class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
>
<li>
<router-link
to="/parks/"
class="hover:text-blue-600 dark:hover:text-blue-400"
>Parks</router-link
>
</li>
<li>/</li>
<li>
<router-link
:to="`/parks/${parkSlug}/`"
class="hover:text-blue-600 dark:hover:text-blue-400"
>{{ parkName }}</router-link
>
</li>
<li>/</li>
<li class="font-medium text-gray-900 dark:text-white">Rides</li>
</ol>
</nav>
<!-- Quick Filter Bar (when sidebar is hidden) -->
<div
v-if="!filterStore.uiState.sidebarVisible"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8"
>
<div class="flex flex-col md:flex-row gap-4 items-center">
<div class="flex-1">
<input
v-model="filterStore.searchState.query"
type="text"
placeholder="Search rides..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<button
@click="filterStore.toggleSidebar()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
></path>
</svg>
More Filters
</button>
</div>
</div>
<!-- Active Filters Display -->
<div v-if="hasActiveFilters" class="mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-gray-900 dark:text-white">Active Filters</h3>
<button
@click="filterStore.clearAllFilters()"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
>
Clear All
</button>
</div>
<div class="flex flex-wrap gap-2">
<!-- Search filter chip -->
<div
v-if="filterStore.searchState.query"
class="flex items-center gap-1 px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm"
>
<span>Search: "{{ filterStore.searchState.query }}"</span>
<button
@click="filterStore.clearSearchQuery()"
class="ml-1 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</button>
</div>
<!-- Other active filter chips will be handled by the filter store -->
</div>
</div>
</div>
<!-- Results Summary -->
<div class="mb-6">
<div class="flex items-center justify-between">
<p class="text-gray-600 dark:text-gray-400">
<span v-if="loading">Loading rides...</span>
<span v-else-if="error" class="text-red-600 dark:text-red-400">{{
error
}}</span>
<span v-else>
{{ totalCount }} {{ totalCount === 1 ? "ride" : "rides" }} found
<span v-if="hasActiveFilters">(filtered)</span>
</span>
</p>
<!-- Sort Controls -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Sort by:</label>
<select
v-model="filterStore.filters.ordering"
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
>
<option value="name">Name</option>
<option value="-average_rating">Rating (High to Low)</option>
<option value="-height">Height (High to Low)</option>
<option value="-speed">Speed (High to Low)</option>
<option value="park_name">Park Name</option>
<option value="category">Category</option>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"
></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading rides...</p>
</div>
<!-- Rides Grid -->
<div
v-else-if="rides.length > 0"
class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="ride in rides"
:key="ride.id"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer"
@click="navigateToRide(ride)"
>
<!-- Ride Image -->
<div
class="h-48 bg-gradient-to-br from-purple-500 via-blue-500 to-teal-500 relative"
>
<div class="absolute inset-0 bg-black bg-opacity-20"></div>
<div class="absolute top-4 right-4">
<span
v-if="ride.featured"
class="px-2 py-1 text-xs font-medium text-white bg-yellow-500 rounded-full"
>
Featured
</span>
</div>
<div class="absolute bottom-4 left-4">
<h3 class="text-white text-xl font-bold">{{ ride.name }}</h3>
<p class="text-white text-sm opacity-90">{{ ride.park_name }}</p>
</div>
</div>
<!-- Ride Info -->
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<svg
class="h-4 w-4 text-yellow-500 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
></path>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ ride.average_rating ? ride.average_rating.toFixed(1) : "N/A" }}
</span>
</div>
<span
class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 dark:bg-blue-700 dark:text-blue-50 rounded-full"
>
{{ ride.category_display }}
</span>
</div>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
{{ ride.description }}
</p>
<!-- Stats -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div v-if="ride.height" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Height:</span> {{ ride.height }}ft
</div>
<div v-if="ride.speed" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Speed:</span> {{ ride.speed }}mph
</div>
<div v-if="ride.length" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Length:</span> {{ ride.length }}ft
</div>
<div v-if="ride.duration" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Duration:</span> {{ ride.duration }}
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<svg
class="h-16 w-16 mx-auto text-gray-300 dark:text-gray-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
No rides found
</h3>
<p class="text-gray-500 dark:text-gray-400">
Try adjusting your search or filter criteria.
</p>
<button
v-if="hasActiveFilters"
@click="filterStore.clearAllFilters()"
class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Clear All Filters
</button>
</div>
<!-- Load More Button -->
<div v-if="rides.length > 0 && !loading" class="text-center mt-8">
<button
v-if="hasNextPage"
@click="loadMore"
:disabled="loading"
class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ loading ? "Loading..." : "Load More Rides" }}
</button>
</div>
<!-- Quick Actions Bar -->
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<!-- Search Input -->
<div class="relative">
<i class="pi pi-search absolute left-3 top-1/2 transform -translate-y-1/2 text-surface-500 dark:text-surface-400"></i>
<InputText
placeholder="Search rides, parks, manufacturers..."
class="pl-10 w-[300px]"
:value="searchQuery"
@input="handleSearchInput"
/>
</div>
</div>
<!-- Filter Controls -->
<div class="flex items-center gap-2">
<!-- Quick Filters -->
<Button
severity="secondary"
outlined
size="small"
@click="toggleQuickFilters"
aria-haspopup="true"
aria-controls="quick_filters_menu"
>
<i class="pi pi-filter mr-2"></i>
Quick Filters
<Badge v-if="quickFiltersCount > 0" :value="quickFiltersCount" class="ml-2" />
</Button>
<Menu
ref="quickFiltersMenu"
id="quick_filters_menu"
:model="quickFilterItems"
:popup="true"
/>
<!-- Advanced Filters -->
<Button
severity="secondary"
outlined
size="small"
@click="toggleAdvancedFilters"
aria-haspopup="true"
aria-controls="advanced_filters_menu"
>
<i class="pi pi-sliders-h mr-2"></i>
Advanced
<Badge v-if="advancedFiltersCount > 0" :value="advancedFiltersCount" class="ml-2" />
</Button>
<!-- Advanced Filters Dialog -->
<Dialog
v-model:visible="showAdvancedFilters"
modal
header="Advanced Filters"
:style="{ width: '32rem' }"
:breakpoints="{ '1199px': '75vw', '575px': '90vw' }"
>
<div class="space-y-6">
<!-- Height Range -->
<div class="space-y-3">
<label class="text-sm font-medium text-surface-900 dark:text-surface-50">Height Range</label>
<div class="px-3">
<Slider
v-model="heightRange"
:max="200"
:min="0"
:step="5"
range
class="w-full"
/>
<div class="flex justify-between text-xs text-surface-600 dark:text-surface-400 mt-2">
<span>{{ heightRange[0] }}m</span>
<span>{{ heightRange[1] }}m</span>
</div>
</div>
</div>
<!-- Speed Range -->
<div class="space-y-3">
<label class="text-sm font-medium text-surface-900 dark:text-surface-50">Speed Range</label>
<div class="px-3">
<Slider
v-model="speedRange"
:max="200"
:min="0"
:step="5"
range
class="w-full"
/>
<div class="flex justify-between text-xs text-surface-600 dark:text-surface-400 mt-2">
<span>{{ speedRange[0] }}km/h</span>
<span>{{ speedRange[1] }}km/h</span>
</div>
</div>
</div>
<!-- Status -->
<div class="space-y-3">
<label class="text-sm font-medium text-surface-900 dark:text-surface-50">Status</label>
<Dropdown
v-model="statusFilter"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="Select status"
class="w-full"
/>
</div>
<Divider />
<div class="flex gap-2">
<Button class="flex-1" @click="applyAdvancedFilters">Apply Filters</Button>
<Button severity="secondary" outlined @click="clearAdvancedFilters">
<i class="pi pi-refresh"></i>
</Button>
</div>
</div>
</Dialog>
<!-- Sort -->
<Dropdown
v-model="sortBy"
:options="sortOptions"
optionLabel="label"
optionValue="value"
placeholder="Sort by..."
class="w-[180px]"
/>
</div>
</div>
<!-- Active Filters -->
<div v-if="hasActiveFilters" class="flex items-center gap-2">
<span class="text-sm font-medium text-surface-900 dark:text-surface-50">Active Filters:</span>
<div class="flex flex-wrap gap-2">
<Badge
v-for="filter in activeFiltersList"
:key="filter.key"
:value="filter.label"
severity="secondary"
class="cursor-pointer hover:bg-red-100 hover:text-red-800 dark:hover:bg-red-900 dark:hover:text-red-200"
@click="removeFilter(filter.key)"
>
<template #default>
{{ filter.label }}
<i class="pi pi-times ml-1 text-xs"></i>
</template>
</Badge>
</div>
<Button severity="secondary" text size="small" @click="clearAllFilters">
<i class="pi pi-times mr-1"></i>
Clear All
</Button>
</div>
<!-- Results Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm text-surface-600 dark:text-surface-400">
{{ totalCount }} {{ totalCount === 1 ? 'ride' : 'rides' }} found
</span>
<div v-if="loading" class="flex items-center gap-2 text-surface-600 dark:text-surface-400">
<ProgressSpinner style="width: 16px; height: 16px" strokeWidth="4" />
<span class="text-sm">Loading...</span>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 6" :key="i" class="overflow-hidden">
<template #content>
<div class="space-y-4">
<Skeleton height="12rem" />
<Skeleton height="1rem" />
<Skeleton height="0.75rem" width="66%" />
<Skeleton height="0.75rem" width="50%" />
</div>
</template>
</Card>
</div>
<!-- Rides Grid -->
<div
v-else-if="filteredRides && filteredRides.length > 0"
:class="viewMode === 'grid' ? 'grid gap-6 md:grid-cols-2 lg:grid-cols-3' : 'space-y-4'"
>
<Card
v-for="ride in filteredRides"
:key="ride.id"
class="group overflow-hidden hover:shadow-lg transition-all duration-300 cursor-pointer"
@click="navigateToRide(ride)"
>
<template #header>
<!-- Ride Image -->
<div class="relative h-48 bg-gradient-to-br from-blue-400 via-purple-500 to-pink-500">
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
<div class="absolute top-4 right-4">
<Badge v-if="ride?.featured" severity="warning" class="text-white">
<i class="pi pi-star mr-1"></i>
Featured
</Badge>
</div>
<div class="absolute bottom-4 left-4 right-4">
<h3 class="text-white text-xl font-bold mb-1">
{{ ride?.name }}
</h3>
<p class="text-white/90 text-sm flex items-center gap-1">
<i class="pi pi-map-marker text-xs"></i>
{{ ride?.park_name }}
</p>
</div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent"
></div>
</div>
</template>
<template #content>
<!-- Ride Info -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<i class="pi pi-star-fill text-yellow-500"></i>
<span class="text-sm font-medium">
{{ ride?.average_rating ? ride.average_rating.toFixed(1) : 'N/A' }}
</span>
</div>
</div>
<Badge severity="info" :value="ride?.category_display" />
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-4 line-clamp-2">
{{ ride?.description || 'Experience the thrill of this amazing ride!' }}
</p>
<!-- Stats -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div v-if="ride?.height" class="text-center p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<div class="font-semibold text-orange-700 dark:text-orange-300 text-lg">{{ ride.height }}m</div>
<div class="text-xs text-orange-600 dark:text-orange-400">Height</div>
</div>
<div v-if="ride?.speed" class="text-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="font-semibold text-blue-700 dark:text-blue-300 text-lg">{{ ride.speed }}km/h</div>
<div class="text-xs text-blue-600 dark:text-blue-400">Speed</div>
</div>
<div v-if="ride?.length" class="text-center p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="font-semibold text-green-700 dark:text-green-300 text-lg">{{ ride.length }}m</div>
<div class="text-xs text-green-600 dark:text-green-400">Length</div>
</div>
<div v-if="ride?.duration" class="text-center p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div class="font-semibold text-purple-700 dark:text-purple-300 text-lg">
{{ ride.duration }}
</div>
<div class="text-xs text-purple-600 dark:text-purple-400">Duration</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Empty State -->
<Card v-else class="text-center py-12">
<template #content>
<i class="pi pi-search text-6xl text-surface-400 dark:text-surface-600 mb-4"></i>
<h3 class="text-lg font-medium mb-2 text-surface-900 dark:text-surface-50">No rides found</h3>
<p class="text-surface-600 dark:text-surface-400 mb-4">Try adjusting your search or filter criteria</p>
<Button v-if="hasActiveFilters" @click="clearAllFilters">Clear All Filters</Button>
</template>
</Card>
<!-- Load More -->
<div v-if="hasNextPage && !loading" class="text-center">
<Button size="large" @click="loadMore">
Load More Rides
<i class="pi pi-chevron-down ml-2"></i>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRideFiltering, useParkRideFiltering } from "@/composables/useRideFiltering";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import RideFilterSidebar from "@/components/filters/RideFilterSidebar.vue";
import type { Ride } from "@/types";
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRideFiltering, useParkRideFiltering } from '@/composables/useRideFiltering'
import { useRideFilteringStore } from '@/stores/rideFiltering'
import type { Ride } from '@/types'
const route = useRoute();
const router = useRouter();
// Import PrimeVue components
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Badge from 'primevue/badge'
import Divider from 'primevue/divider'
import Slider from 'primevue/slider'
import Dropdown from 'primevue/dropdown'
import Menu from 'primevue/menu'
import Dialog from 'primevue/dialog'
import ProgressSpinner from 'primevue/progressspinner'
import Skeleton from 'primevue/skeleton'
const route = useRoute()
const router = useRouter()
// Get park context from route params
const parkSlug = computed(() => route.params.parkSlug as string);
const parkName = ref("");
const parkSlug = computed(() => route.params.parkSlug as string)
const parkName = ref('')
// Initialize filtering store
const filterStore = useRideFilteringStore();
const filterStore = useRideFilteringStore()
// Use appropriate composable based on context
const rideFiltering = parkSlug.value
? useParkRideFiltering(parkSlug.value, {})
: useRideFiltering({});
: useRideFiltering({})
const {
isLoading: loading,
@@ -346,47 +365,246 @@ const {
fetchRides,
loadMore,
fetchFilterOptions,
} = rideFiltering;
} = rideFiltering
// UI State
const viewMode = ref<'grid' | 'list'>('grid')
const searchQuery = ref('')
const sortBy = ref('-average_rating')
const showAdvancedFilters = ref(false)
// Filter State
const heightRange = ref([0, 200])
const speedRange = ref([0, 200])
const statusFilter = ref('all')
// Menu refs
const quickFiltersMenu = ref()
// Dropdown options
const sortOptions = [
{ label: 'Name (A-Z)', value: 'name' },
{ label: 'Highest Rated', value: '-average_rating' },
{ label: 'Tallest First', value: '-height' },
{ label: 'Fastest First', value: '-speed' },
]
const statusOptions = [
{ label: 'All Rides', value: 'all' },
{ label: 'Operating', value: 'operating' },
{ label: 'Closed', value: 'closed' },
{ label: 'Under Construction', value: 'under_construction' },
]
// Quick filter menu items
const quickFilterItems = [
{
label: 'Filter by ride type',
items: [
{
label: 'Roller Coasters',
icon: 'pi pi-angle-double-up',
command: () => applyQuickFilter('roller_coaster')
},
{
label: 'Water Rides',
icon: 'pi pi-cloud-download',
command: () => applyQuickFilter('water_ride')
},
{
label: 'Family Rides',
icon: 'pi pi-users',
command: () => applyQuickFilter('family')
},
{
label: 'Thrill Rides',
icon: 'pi pi-bolt',
command: () => applyQuickFilter('thrill')
}
]
},
{
separator: true
},
{
label: 'Clear Filters',
icon: 'pi pi-refresh',
command: () => clearQuickFilters()
}
]
// Computed properties
const filteredRides = computed(() => {
if (!rides.value || !Array.isArray(rides.value)) {
return []
}
return rides.value
})
const quickFiltersCount = computed(() => {
// Count active quick filters
let count = 0
if (filterStore.filters.category) count++
return count
})
const advancedFiltersCount = computed(() => {
// Count active advanced filters
let count = 0
if (heightRange.value[0] > 0 || heightRange.value[1] < 200) count++
if (speedRange.value[0] > 0 || speedRange.value[1] < 200) count++
if (statusFilter.value !== 'all') count++
return count
})
const activeFiltersList = computed(() => {
const filters = []
if (filterStore.filters.category) {
filters.push({ key: 'category', label: `Category: ${filterStore.filters.category}` })
}
if (heightRange.value[0] > 0 || heightRange.value[1] < 200) {
filters.push({
key: 'height',
label: `Height: ${heightRange.value[0]}-${heightRange.value[1]}m`,
})
}
if (speedRange.value[0] > 0 || speedRange.value[1] < 200) {
filters.push({
key: 'speed',
label: `Speed: ${speedRange.value[0]}-${speedRange.value[1]}km/h`,
})
}
if (statusFilter.value !== 'all') {
filters.push({ key: 'status', label: `Status: ${statusFilter.value}` })
}
return filters
})
// Methods
const handleSearchInput = (event: Event) => {
const target = event.target as HTMLInputElement
searchQuery.value = target.value
filterStore.updateFilters({ search: target.value })
}
const setViewMode = (mode: 'grid' | 'list') => {
viewMode.value = mode
}
const toggleQuickFilters = (event: Event) => {
quickFiltersMenu.value.toggle(event)
}
const toggleAdvancedFilters = () => {
showAdvancedFilters.value = !showAdvancedFilters.value
}
const applyQuickFilter = (category: string) => {
filterStore.updateFilters({ category })
}
const clearQuickFilters = () => {
filterStore.updateFilters({ category: undefined })
}
const applyAdvancedFilters = () => {
filterStore.updateFilters({
height_min: heightRange.value[0] > 0 ? heightRange.value[0] : undefined,
height_max: heightRange.value[1] < 200 ? heightRange.value[1] : undefined,
speed_min: speedRange.value[0] > 0 ? speedRange.value[0] : undefined,
speed_max: speedRange.value[1] < 200 ? speedRange.value[1] : undefined,
status: statusFilter.value !== 'all' ? statusFilter.value : undefined,
})
showAdvancedFilters.value = false
}
const clearAdvancedFilters = () => {
heightRange.value = [0, 200]
speedRange.value = [0, 200]
statusFilter.value = 'all'
filterStore.updateFilters({
height_min: undefined,
height_max: undefined,
speed_min: undefined,
speed_max: undefined,
status: undefined,
})
}
const clearAllFilters = () => {
searchQuery.value = ''
heightRange.value = [0, 200]
speedRange.value = [0, 200]
statusFilter.value = 'all'
filterStore.clearAllFilters()
}
const removeFilter = (key: string) => {
switch (key) {
case 'category':
filterStore.updateFilters({ category: undefined })
break
case 'height':
heightRange.value = [0, 200]
filterStore.updateFilters({ height_min: undefined, height_max: undefined })
break
case 'speed':
speedRange.value = [0, 200]
filterStore.updateFilters({ speed_min: undefined, speed_max: undefined })
break
case 'status':
statusFilter.value = 'all'
filterStore.updateFilters({ status: undefined })
break
}
}
const navigateToRide = (ride: Ride) => {
if (ride?.parkSlug && ride?.slug) {
router.push(`/parks/${ride.parkSlug}/rides/${ride.slug}/`)
}
}
// Watch for sort changes
watch(sortBy, (newSort) => {
filterStore.updateFilters({ ordering: newSort })
})
// Initialize filter store with context
onMounted(async () => {
try {
// Set context in store
if (parkSlug.value) {
filterStore.setContext("park", parkSlug.value);
filterStore.setContext('park', parkSlug.value)
// Fetch park info to get the park name
try {
const { api } = await import("@/services/api");
const parkData = await api.parks.getPark(parkSlug.value);
parkName.value = parkData.name;
const { api } = await import('@/services/api')
const parkData = await api.parks.getPark(parkSlug.value)
parkName.value = parkData.name
} catch (err) {
console.warn("Could not fetch park name:", err);
console.warn('Could not fetch park name:', err)
}
} else {
filterStore.setContext("global");
filterStore.setContext('global')
}
// Load filter options and initial rides
await Promise.all([fetchFilterOptions(), fetchRides()]);
await Promise.all([fetchFilterOptions(), fetchRides()])
} catch (error) {
console.error("Failed to initialize ride list:", error);
console.error('Failed to initialize ride list:', error)
}
});
})
// Watch for filter changes in store and update composable
watch(
() => filterStore.allFilters,
(newFilters) => {
// Update the composable's filters
rideFiltering.updateFilters(newFilters);
rideFiltering.updateFilters(newFilters)
},
{ deep: true }
);
const navigateToRide = (ride: Ride) => {
router.push(`/parks/${ride.parkSlug}/rides/${ride.slug}/`);
};
{ deep: true },
)
</script>
<style scoped>
@@ -396,4 +614,8 @@ const navigateToRide = (ride: Ride) => {
-webkit-box-orient: vertical;
overflow: hidden;
}
.pattern-dots {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.05'%3E%3Ccircle cx='30' cy='30' r='4'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
</style>

View File

@@ -1,5 +1,6 @@
import { fileURLToPath, URL } from 'node:url'
import Components from 'unplugin-vue-components/vite';
import {PrimeVueResolver} from '@primevue/auto-import-resolver';
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
@@ -11,6 +12,10 @@ export default defineConfig(({ mode }) => ({
vue(),
vueDevTools(),
tailwindcss(),
Components({
resolvers: [PrimeVueResolver()],
dts: true, // Generate TypeScript declaration
}),
],
resolve: {
alias: {