mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:11:10 -05:00
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:
2
frontend/.gitattributes
vendored
2
frontend/.gitattributes
vendored
@@ -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
3
frontend/.gitignore
vendored
@@ -31,3 +31,6 @@ coverage
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
# pixi environments
|
||||
.pixi/*
|
||||
!.pixi/config.toml
|
||||
|
||||
1272
frontend/bun.lock
Normal file
1272
frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
216
frontend/components.d.ts
vendored
Normal file
216
frontend/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
856
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
frontend/pnpm-workspace.yaml
Normal file
2
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- vue-demi
|
||||
@@ -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>
|
||||
|
||||
387
frontend/src/components/AppSidebar.vue
Normal file
387
frontend/src/components/AppSidebar.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
172
frontend/src/components/layout/PrimeThemeController.vue
Normal file
172
frontend/src/components/layout/PrimeThemeController.vue
Normal 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>
|
||||
106
frontend/src/components/primevue/PrimeBadge.vue
Normal file
106
frontend/src/components/primevue/PrimeBadge.vue
Normal 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>
|
||||
161
frontend/src/components/primevue/PrimeButton.vue
Normal file
161
frontend/src/components/primevue/PrimeButton.vue
Normal 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>
|
||||
188
frontend/src/components/primevue/PrimeCard.vue
Normal file
188
frontend/src/components/primevue/PrimeCard.vue
Normal 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>
|
||||
225
frontend/src/components/primevue/PrimeDialog.vue
Normal file
225
frontend/src/components/primevue/PrimeDialog.vue
Normal 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>
|
||||
251
frontend/src/components/primevue/PrimeInput.vue
Normal file
251
frontend/src/components/primevue/PrimeInput.vue
Normal 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>
|
||||
211
frontend/src/components/primevue/PrimeProgress.vue
Normal file
211
frontend/src/components/primevue/PrimeProgress.vue
Normal 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>
|
||||
296
frontend/src/components/primevue/PrimeSelect.vue
Normal file
296
frontend/src/components/primevue/PrimeSelect.vue
Normal 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>
|
||||
129
frontend/src/components/primevue/PrimeSkeleton.vue
Normal file
129
frontend/src/components/primevue/PrimeSkeleton.vue
Normal 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>
|
||||
46
frontend/src/components/primevue/index.ts
Normal file
46
frontend/src/components/primevue/index.ts
Normal 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'
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
frontend/src/components/test/PrimeVueTest.vue
Normal file
107
frontend/src/components/test/PrimeVueTest.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
211
frontend/src/theme/primevue-theme.ts
Normal file
211
frontend/src/theme/primevue-theme.ts
Normal 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
164
frontend/src/types/declarations.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user