mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:31:08 -05:00
feat: Implement comprehensive ride filtering system with API integration
- Added `useRideFiltering` composable for managing ride filters and fetching rides from the API. - Created `useParkRideFiltering` for park-specific ride filtering. - Developed `useTheme` composable for theme management with localStorage support. - Established `rideFiltering` Pinia store for centralized state management of ride filters and UI state. - Defined enhanced filter types in `filters.ts` for better type safety and clarity. - Built `RideFilteringPage.vue` to provide a user interface for filtering rides with responsive design. - Integrated filter sidebar and ride list display components for a cohesive user experience. - Added support for filter presets and search suggestions. - Implemented computed properties for active filters, average ratings, and operating counts.
This commit is contained in:
109
frontend/src/components/filters/ActiveFilterChip.vue
Normal file
109
frontend/src/components/filters/ActiveFilterChip.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div
|
||||
class="active-filter-chip inline-flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-full text-sm"
|
||||
:class="{
|
||||
'cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800': !disabled && removable,
|
||||
'opacity-50': disabled,
|
||||
}"
|
||||
>
|
||||
<!-- Filter icon -->
|
||||
<Icon :name="icon" class="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-700 dark:text-blue-300 truncate">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Count indicator -->
|
||||
<span
|
||||
v-if="count !== undefined"
|
||||
class="bg-blue-200 dark:bg-blue-700 text-blue-800 dark:text-blue-200 px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="removable && !disabled"
|
||||
@click="$emit('remove')"
|
||||
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"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import Icon from "@/components/ui/Icon.vue";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: any;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
removable?: boolean;
|
||||
disabled?: boolean;
|
||||
formatValue?: (value: any) => string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
icon: "filter",
|
||||
removable: true,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
// Computed
|
||||
const displayValue = computed(() => {
|
||||
if (props.formatValue) {
|
||||
return props.formatValue(props.value);
|
||||
}
|
||||
|
||||
if (Array.isArray(props.value)) {
|
||||
if (props.value.length === 1) {
|
||||
return String(props.value[0]);
|
||||
} else if (props.value.length <= 3) {
|
||||
return props.value.join(", ");
|
||||
} else {
|
||||
return `${props.value.slice(0, 2).join(", ")} +${props.value.length - 2} more`;
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
// Handle date range objects
|
||||
if ("start" in props.value && "end" in props.value) {
|
||||
return `${props.value.start} to ${props.value.end}`;
|
||||
}
|
||||
return JSON.stringify(props.value);
|
||||
}
|
||||
|
||||
return String(props.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.active-filter-chip {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.active-filter-chip:hover {
|
||||
@apply shadow-sm;
|
||||
}
|
||||
</style>
|
||||
415
frontend/src/components/filters/DateRangeFilter.vue
Normal file
415
frontend/src/components/filters/DateRangeFilter.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="date-range-filter">
|
||||
<label 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>
|
||||
|
||||
<!-- Date inputs -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Start date -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1"> From </label>
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="startDateInput"
|
||||
type="date"
|
||||
:value="startDate"
|
||||
@input="handleStartDateChange"
|
||||
@blur="emitChange"
|
||||
:min="minDate"
|
||||
:max="endDate || maxDate"
|
||||
class="date-input w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
:disabled="disabled"
|
||||
:placeholder="startPlaceholder"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- End date -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1"> To </label>
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="endDateInput"
|
||||
type="date"
|
||||
:value="endDate"
|
||||
@input="handleEndDateChange"
|
||||
@blur="emitChange"
|
||||
:min="startDate || minDate"
|
||||
:max="maxDate"
|
||||
class="date-input w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
:disabled="disabled"
|
||||
:placeholder="endPlaceholder"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current selection display -->
|
||||
<div v-if="hasSelection" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Selected:</span>
|
||||
{{ formatDateRange(startDate, endDate) }}
|
||||
<span v-if="duration" class="text-gray-500 ml-2"> ({{ duration }}) </span>
|
||||
</div>
|
||||
|
||||
<!-- 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="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.label"
|
||||
@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
|
||||
),
|
||||
}"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation message -->
|
||||
<div v-if="validationMessage" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{{ validationMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
v-if="clearable && hasSelection"
|
||||
@click="clearDates"
|
||||
class="mt-3 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
:disabled="disabled"
|
||||
>
|
||||
Clear dates
|
||||
</button>
|
||||
|
||||
<!-- Helper text -->
|
||||
<div v-if="helperText" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ helperText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import Icon from "@/components/ui/Icon.vue";
|
||||
|
||||
interface DatePreset {
|
||||
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;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
startPlaceholder: "Start date",
|
||||
endPlaceholder: "End date",
|
||||
required: false,
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
showPresets: true,
|
||||
validateRange: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [value: [string, string] | undefined];
|
||||
}>();
|
||||
|
||||
// Local state
|
||||
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);
|
||||
});
|
||||
|
||||
const duration = computed(() => {
|
||||
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));
|
||||
|
||||
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 lastWeek = new Date(today);
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
|
||||
const lastMonth = new Date(today);
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||
|
||||
const lastYear = new Date(today);
|
||||
lastYear.setFullYear(lastYear.getFullYear() - 1);
|
||||
|
||||
const thisYear = new Date(today.getFullYear(), 0, 1);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Today",
|
||||
startDate: () => formatDate(today),
|
||||
endDate: () => formatDate(today),
|
||||
},
|
||||
{
|
||||
label: "Yesterday",
|
||||
startDate: () => formatDate(yesterday),
|
||||
endDate: () => formatDate(yesterday),
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
startDate: () => formatDate(lastWeek),
|
||||
endDate: () => formatDate(today),
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
startDate: () => formatDate(lastMonth),
|
||||
endDate: () => formatDate(today),
|
||||
},
|
||||
{
|
||||
label: "This year",
|
||||
startDate: () => formatDate(thisYear),
|
||||
endDate: () => formatDate(today),
|
||||
},
|
||||
{
|
||||
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];
|
||||
};
|
||||
|
||||
const formatDateRange = (start: string, end: string): string => {
|
||||
if (!start || !end) return "";
|
||||
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
const formatOptions: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
if (start === end) {
|
||||
return startDate.toLocaleDateString(undefined, formatOptions);
|
||||
}
|
||||
|
||||
return `${startDate.toLocaleDateString(
|
||||
undefined,
|
||||
formatOptions
|
||||
)} - ${endDate.toLocaleDateString(undefined, formatOptions)}`;
|
||||
};
|
||||
|
||||
const validateDates = (): boolean => {
|
||||
validationMessage.value = "";
|
||||
|
||||
if (!props.validateRange) return true;
|
||||
|
||||
if (startDate.value && 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.minDate && startDate.value) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.maxDate && endDate.value) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleStartDateChange = (event: Event) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (event: Event) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
if (!validateDates()) return;
|
||||
|
||||
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;
|
||||
|
||||
startDate.value = start;
|
||||
endDate.value = end;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const isActivePreset = (preset: DatePreset): boolean => {
|
||||
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;
|
||||
|
||||
return startDate.value === start && endDate.value === end;
|
||||
};
|
||||
|
||||
const clearDates = () => {
|
||||
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] || "";
|
||||
} else {
|
||||
startDate.value = "";
|
||||
endDate.value = "";
|
||||
}
|
||||
validationMessage.value = "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-input {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
@apply ring-2 ring-blue-500 border-blue-500;
|
||||
}
|
||||
|
||||
.date-input:disabled {
|
||||
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
/* Custom date picker styles */
|
||||
.date-input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input::-webkit-datetime-edit {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.date-input::-webkit-datetime-edit-fields-wrapper {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.date-input::-webkit-datetime-edit-text {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.date-input[type="date"]::-webkit-input-placeholder {
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.date-input[type="date"]::-moz-placeholder {
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
|
||||
/* Edge */
|
||||
.date-input[type="date"]::-ms-input-placeholder {
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
</style>
|
||||
69
frontend/src/components/filters/FilterSection.vue
Normal file
69
frontend/src/components/filters/FilterSection.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="filter-section">
|
||||
<button
|
||||
@click="$emit('toggle')"
|
||||
class="section-header w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-controls="`section-${id}`"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-96"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-96"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-show="isExpanded"
|
||||
:id="`section-${id}`"
|
||||
class="section-content overflow-hidden border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div class="p-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from "@/components/ui/Icon.vue";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
defineEmits<{
|
||||
toggle: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-section {
|
||||
@apply border-b border-gray-100 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
}
|
||||
</style>
|
||||
229
frontend/src/components/filters/PresetItem.vue
Normal file
229
frontend/src/components/filters/PresetItem.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<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"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-600': 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>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="preset.description"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 truncate"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import Icon from "@/components/ui/Icon.vue";
|
||||
import type { FilterPreset } from "@/types/filters";
|
||||
|
||||
interface Props {
|
||||
preset: FilterPreset;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
isGlobal?: boolean;
|
||||
disabled?: boolean;
|
||||
showFavorite?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false,
|
||||
isDefault: false,
|
||||
isGlobal: false,
|
||||
disabled: false,
|
||||
showFavorite: true,
|
||||
showActions: true,
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
select: [];
|
||||
"toggle-favorite": [];
|
||||
rename: [];
|
||||
duplicate: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
// Local state
|
||||
const showMenu = ref(false);
|
||||
|
||||
// Computed
|
||||
const filterCount = computed(() => {
|
||||
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++;
|
||||
|
||||
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));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "Today";
|
||||
} else if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
} else if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
|
||||
} else {
|
||||
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>
|
||||
.preset-item {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.preset-item:hover {
|
||||
@apply shadow-sm;
|
||||
}
|
||||
</style>
|
||||
431
frontend/src/components/filters/RangeFilter.vue
Normal file
431
frontend/src/components/filters/RangeFilter.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<div class="range-filter">
|
||||
<label 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>
|
||||
|
||||
<!-- Current values display -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Dual range slider -->
|
||||
<div class="relative">
|
||||
<div class="range-slider-container relative">
|
||||
<!-- Background track -->
|
||||
<div
|
||||
class="range-track absolute w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full"
|
||||
></div>
|
||||
|
||||
<!-- Active track -->
|
||||
<div
|
||||
class="range-track-active absolute h-2 bg-blue-500 rounded-full"
|
||||
:style="activeTrackStyle"
|
||||
></div>
|
||||
|
||||
<!-- Min range input -->
|
||||
<input
|
||||
ref="minInput"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="currentMin"
|
||||
@input="handleMinChange"
|
||||
@change="emitChange"
|
||||
class="range-input range-input-min absolute w-full h-2 bg-transparent appearance-none cursor-pointer"
|
||||
:disabled="disabled"
|
||||
:aria-label="`Minimum ${label.toLowerCase()}`"
|
||||
/>
|
||||
|
||||
<!-- Max range input -->
|
||||
<input
|
||||
ref="maxInput"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="currentMax"
|
||||
@input="handleMaxChange"
|
||||
@change="emitChange"
|
||||
class="range-input range-input-max absolute w-full h-2 bg-transparent appearance-none cursor-pointer"
|
||||
:disabled="disabled"
|
||||
:aria-label="`Maximum ${label.toLowerCase()}`"
|
||||
/>
|
||||
|
||||
<!-- Min thumb -->
|
||||
<div
|
||||
class="range-thumb range-thumb-min absolute w-5 h-5 bg-white border-2 border-blue-500 rounded-full shadow-md cursor-pointer transform -translate-y-1.5"
|
||||
:style="minThumbStyle"
|
||||
@mousedown="startDrag('min', $event)"
|
||||
@touchstart="startDrag('min', $event)"
|
||||
></div>
|
||||
|
||||
<!-- Max thumb -->
|
||||
<div
|
||||
class="range-thumb range-thumb-max absolute w-5 h-5 bg-white border-2 border-blue-500 rounded-full shadow-md cursor-pointer transform -translate-y-1.5"
|
||||
:style="maxThumbStyle"
|
||||
@mousedown="startDrag('max', $event)"
|
||||
@touchstart="startDrag('max', $event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Value tooltips -->
|
||||
<div
|
||||
v-if="showTooltips && isDragging"
|
||||
class="absolute -top-8"
|
||||
:style="{ left: minThumbPosition + '%' }"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap transform -translate-x-1/2"
|
||||
>
|
||||
{{ unit ? `${currentMin} ${unit}` : currentMin }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showTooltips && isDragging"
|
||||
class="absolute -top-8"
|
||||
:style="{ left: maxThumbPosition + '%' }"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap transform -translate-x-1/2"
|
||||
>
|
||||
{{ unit ? `${currentMax} ${unit}` : currentMax }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual input fields -->
|
||||
<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 || "" }}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
:min="min"
|
||||
:max="currentMax"
|
||||
:step="step"
|
||||
:value="currentMin"
|
||||
@input="handleMinInputChange"
|
||||
@blur="emitChange"
|
||||
class="w-full px-3 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Max {{ unit || "" }}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
:min="currentMin"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="currentMax"
|
||||
@input="handleMaxInputChange"
|
||||
@blur="emitChange"
|
||||
class="w-full px-3 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset button -->
|
||||
<button
|
||||
v-if="clearable && hasChanges"
|
||||
@click="reset"
|
||||
class="mt-3 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
:disabled="disabled"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
|
||||
<!-- Step size indicator -->
|
||||
<div v-if="showStepInfo" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Step: {{ step }}{{ unit ? ` ${unit}` : "" }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
step: 1,
|
||||
required: false,
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
showInputs: false,
|
||||
showTooltips: true,
|
||||
showStepInfo: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
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);
|
||||
|
||||
// Computed
|
||||
const hasChanges = computed(() => {
|
||||
return currentMin.value !== props.min || currentMax.value !== props.max;
|
||||
});
|
||||
|
||||
const minThumbPosition = computed(() => {
|
||||
return ((currentMin.value - props.min) / (props.max - props.min)) * 100;
|
||||
});
|
||||
|
||||
const maxThumbPosition = computed(() => {
|
||||
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;
|
||||
|
||||
// Ensure min doesn't exceed max
|
||||
if (currentMin.value >= currentMax.value) {
|
||||
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;
|
||||
|
||||
// Ensure max doesn't go below min
|
||||
if (currentMax.value <= currentMin.value) {
|
||||
currentMax.value = currentMin.value + props.step;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinInputChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = Number(target.value);
|
||||
|
||||
if (value >= props.min && value < currentMax.value) {
|
||||
currentMin.value = value;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaxInputChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = Number(target.value);
|
||||
|
||||
if (value <= props.max && value > currentMin.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 reset = () => {
|
||||
currentMin.value = props.min;
|
||||
currentMax.value = props.max;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
isDragging.value = true;
|
||||
dragType.value = type;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", endDrag);
|
||||
} else {
|
||||
document.addEventListener("touchmove", handleDrag);
|
||||
document.addEventListener("touchend", endDrag);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (event: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value || !dragType.value) 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;
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
isDragging.value = false;
|
||||
dragType.value = null;
|
||||
emitChange();
|
||||
|
||||
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];
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", endDrag);
|
||||
document.removeEventListener("touchmove", handleDrag);
|
||||
document.removeEventListener("touchend", endDrag);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.range-slider-container {
|
||||
height: 2rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.range-input {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.range-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.range-input::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.range-input-min {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.range-input-max {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.range-thumb {
|
||||
z-index: 3;
|
||||
transition: transform 0.1s ease;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.range-thumb:hover {
|
||||
transform: translateY(-1.5px) scale(1.1);
|
||||
}
|
||||
|
||||
.range-thumb:active {
|
||||
transform: translateY(-1.5px) scale(1.2);
|
||||
}
|
||||
|
||||
.range-input:disabled + .range-thumb {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.range-track {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.range-track-active {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Custom focus styles */
|
||||
.range-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.range-input:focus + .range-thumb {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark .range-thumb {
|
||||
border-color: #3b82f6;
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.dark .range-track-active {
|
||||
background: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/components/filters/RideFilterSidebar.vue
Normal file
484
frontend/src/components/filters/RideFilterSidebar.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<div class="ride-filter-sidebar">
|
||||
<!-- Filter Header -->
|
||||
<div class="filter-header">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon name="filter" class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
|
||||
<span
|
||||
v-if="activeFiltersCount > 0"
|
||||
class="px-2 py-1 text-xs font-medium text-white bg-blue-600 rounded-full"
|
||||
>
|
||||
{{ activeFiltersCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile close button -->
|
||||
<button
|
||||
v-if="isMobile"
|
||||
@click="closeFilterForm"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 lg:hidden"
|
||||
aria-label="Close filters"
|
||||
>
|
||||
<Icon name="x" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Actions -->
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<button
|
||||
@click="applyFilters"
|
||||
:disabled="!hasUnsavedChanges"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="clearAllFilters"
|
||||
:disabled="!hasActiveFilters"
|
||||
class="px-4 py-2 text-sm 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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="expandAllSections"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Expand all sections"
|
||||
>
|
||||
<Icon name="chevron-down" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Sections -->
|
||||
<div class="filter-sections flex-1 overflow-y-auto">
|
||||
<!-- Search Section -->
|
||||
<FilterSection
|
||||
id="search"
|
||||
title="Search"
|
||||
:is-expanded="formState.expandedSections.search"
|
||||
@toggle="toggleSection('search')"
|
||||
>
|
||||
<SearchFilter />
|
||||
</FilterSection>
|
||||
|
||||
<!-- Basic Filters Section -->
|
||||
<FilterSection
|
||||
id="basic"
|
||||
title="Basic Filters"
|
||||
:is-expanded="formState.expandedSections.basic"
|
||||
@toggle="toggleSection('basic')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<SelectFilter
|
||||
id="category"
|
||||
label="Category"
|
||||
:options="filterOptions?.categories || []"
|
||||
:value="filters.category"
|
||||
@update="updateFilter('category', $event)"
|
||||
multiple
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
id="status"
|
||||
label="Status"
|
||||
:options="filterOptions?.statuses || []"
|
||||
:value="filters.status"
|
||||
@update="updateFilter('status', $event)"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
<!-- Manufacturer Section -->
|
||||
<FilterSection
|
||||
id="manufacturer"
|
||||
title="Manufacturer & Design"
|
||||
:is-expanded="formState.expandedSections.manufacturer"
|
||||
@toggle="toggleSection('manufacturer')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<SearchableSelect
|
||||
id="manufacturer"
|
||||
label="Manufacturer"
|
||||
:options="filterOptions?.manufacturers || []"
|
||||
:value="filters.manufacturer"
|
||||
@update="updateFilter('manufacturer', $event)"
|
||||
multiple
|
||||
searchable
|
||||
/>
|
||||
|
||||
<SearchableSelect
|
||||
id="designer"
|
||||
label="Designer"
|
||||
:options="filterOptions?.designers || []"
|
||||
:value="filters.designer"
|
||||
@update="updateFilter('designer', $event)"
|
||||
multiple
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
<!-- Specifications Section -->
|
||||
<FilterSection
|
||||
id="specifications"
|
||||
title="Specifications"
|
||||
:is-expanded="formState.expandedSections.specifications"
|
||||
@toggle="toggleSection('specifications')"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<RangeFilter
|
||||
id="height"
|
||||
label="Height"
|
||||
unit="m"
|
||||
:min-value="filters.height_min"
|
||||
:max-value="filters.height_max"
|
||||
:min-limit="0"
|
||||
:max-limit="200"
|
||||
@update-min="updateFilter('height_min', $event)"
|
||||
@update-max="updateFilter('height_max', $event)"
|
||||
/>
|
||||
|
||||
<RangeFilter
|
||||
id="speed"
|
||||
label="Speed"
|
||||
unit="km/h"
|
||||
:min-value="filters.speed_min"
|
||||
:max-value="filters.speed_max"
|
||||
:min-limit="0"
|
||||
:max-limit="250"
|
||||
@update-min="updateFilter('speed_min', $event)"
|
||||
@update-max="updateFilter('speed_max', $event)"
|
||||
/>
|
||||
|
||||
<RangeFilter
|
||||
id="length"
|
||||
label="Length"
|
||||
unit="m"
|
||||
:min-value="filters.length_min"
|
||||
:max-value="filters.length_max"
|
||||
:min-limit="0"
|
||||
:max-limit="3000"
|
||||
@update-min="updateFilter('length_min', $event)"
|
||||
@update-max="updateFilter('length_max', $event)"
|
||||
/>
|
||||
|
||||
<RangeFilter
|
||||
id="capacity"
|
||||
label="Capacity"
|
||||
unit="people"
|
||||
:min-value="filters.capacity_min"
|
||||
:max-value="filters.capacity_max"
|
||||
:min-limit="1"
|
||||
:max-limit="50"
|
||||
@update-min="updateFilter('capacity_min', $event)"
|
||||
@update-max="updateFilter('capacity_max', $event)"
|
||||
/>
|
||||
|
||||
<RangeFilter
|
||||
id="duration"
|
||||
label="Duration"
|
||||
unit="seconds"
|
||||
:min-value="filters.duration_min"
|
||||
:max-value="filters.duration_max"
|
||||
:min-limit="30"
|
||||
:max-limit="600"
|
||||
@update-min="updateFilter('duration_min', $event)"
|
||||
@update-max="updateFilter('duration_max', $event)"
|
||||
/>
|
||||
|
||||
<RangeFilter
|
||||
id="inversions"
|
||||
label="Inversions"
|
||||
unit=""
|
||||
:min-value="filters.inversions_min"
|
||||
:max-value="filters.inversions_max"
|
||||
:min-limit="0"
|
||||
:max-limit="20"
|
||||
@update-min="updateFilter('inversions_min', $event)"
|
||||
@update-max="updateFilter('inversions_max', $event)"
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
<!-- Dates Section -->
|
||||
<FilterSection
|
||||
id="dates"
|
||||
title="Opening & Closing Dates"
|
||||
:is-expanded="formState.expandedSections.dates"
|
||||
@toggle="toggleSection('dates')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<DateRangeFilter
|
||||
id="opening_date"
|
||||
label="Opening Date"
|
||||
:from-value="filters.opening_date_from"
|
||||
:to-value="filters.opening_date_to"
|
||||
@update-from="updateFilter('opening_date_from', $event)"
|
||||
@update-to="updateFilter('opening_date_to', $event)"
|
||||
/>
|
||||
|
||||
<DateRangeFilter
|
||||
id="closing_date"
|
||||
label="Closing Date"
|
||||
:from-value="filters.closing_date_from"
|
||||
:to-value="filters.closing_date_to"
|
||||
@update-from="updateFilter('closing_date_from', $event)"
|
||||
@update-to="updateFilter('closing_date_to', $event)"
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
<!-- Location Section -->
|
||||
<FilterSection
|
||||
id="location"
|
||||
title="Location"
|
||||
:is-expanded="formState.expandedSections.location"
|
||||
@toggle="toggleSection('location')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<SearchableSelect
|
||||
id="park"
|
||||
label="Park"
|
||||
:options="filterOptions?.parks || []"
|
||||
:value="filters.park"
|
||||
@update="updateFilter('park', $event)"
|
||||
multiple
|
||||
searchable
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
id="country"
|
||||
label="Country"
|
||||
:options="filterOptions?.countries || []"
|
||||
:value="filters.country"
|
||||
@update="updateFilter('country', $event)"
|
||||
multiple
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
id="region"
|
||||
label="Region"
|
||||
:options="filterOptions?.regions || []"
|
||||
:value="filters.region"
|
||||
@update="updateFilter('region', $event)"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
<!-- Advanced Section -->
|
||||
<FilterSection
|
||||
id="advanced"
|
||||
title="Advanced Options"
|
||||
:is-expanded="formState.expandedSections.advanced"
|
||||
@toggle="toggleSection('advanced')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<SelectFilter
|
||||
id="ordering"
|
||||
label="Sort By"
|
||||
:options="sortingOptions"
|
||||
:value="filters.ordering"
|
||||
@update="updateFilter('ordering', $event)"
|
||||
/>
|
||||
|
||||
<SelectFilter
|
||||
id="page_size"
|
||||
label="Results Per Page"
|
||||
:options="pageSizeOptions"
|
||||
:value="filters.page_size"
|
||||
@update="updateFilter('page_size', $event)"
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters Display -->
|
||||
<div
|
||||
v-if="hasActiveFilters"
|
||||
class="active-filters border-t border-gray-200 dark:border-gray-700 p-4"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Active Filters ({{ activeFiltersCount }})
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ActiveFilterChip
|
||||
v-for="filter in activeFiltersList"
|
||||
:key="`${filter.key}-${filter.value}`"
|
||||
:filter="filter"
|
||||
@remove="clearFilter(filter.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Presets -->
|
||||
<div
|
||||
v-if="savedPresets.length > 0"
|
||||
class="filter-presets border-t border-gray-200 dark:border-gray-700 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Saved Presets</h3>
|
||||
<button
|
||||
@click="showSavePresetDialog = true"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
Save Current
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<PresetItem
|
||||
v-for="preset in savedPresets"
|
||||
:key="preset.id"
|
||||
:preset="preset"
|
||||
:is-active="currentPreset === preset.id"
|
||||
@load="loadPreset(preset.id)"
|
||||
@delete="deletePreset(preset.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Preset Dialog -->
|
||||
<SavePresetDialog
|
||||
v-if="showSavePresetDialog"
|
||||
@save="handleSavePreset"
|
||||
@cancel="showSavePresetDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRideFilteringStore } from "@/stores/rideFiltering";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
// Components
|
||||
import FilterSection from "./FilterSection.vue";
|
||||
import SearchFilter from "./SearchFilter.vue";
|
||||
import SelectFilter from "./SelectFilter.vue";
|
||||
import SearchableSelect from "./SearchableSelect.vue";
|
||||
import RangeFilter from "./RangeFilter.vue";
|
||||
import DateRangeFilter from "./DateRangeFilter.vue";
|
||||
import ActiveFilterChip from "./ActiveFilterChip.vue";
|
||||
import PresetItem from "./PresetItem.vue";
|
||||
import SavePresetDialog from "./SavePresetDialog.vue";
|
||||
import Icon from "@/components/ui/Icon.vue";
|
||||
|
||||
// Store
|
||||
const store = useRideFilteringStore();
|
||||
const {
|
||||
filters,
|
||||
filterOptions,
|
||||
formState,
|
||||
hasActiveFilters,
|
||||
activeFiltersCount,
|
||||
activeFiltersList,
|
||||
hasUnsavedChanges,
|
||||
savedPresets,
|
||||
currentPreset,
|
||||
} = storeToRefs(store);
|
||||
|
||||
const {
|
||||
updateFilter,
|
||||
clearFilter,
|
||||
clearAllFilters,
|
||||
applyFilters,
|
||||
toggleSection,
|
||||
expandAllSections,
|
||||
collapseAllSections,
|
||||
closeFilterForm,
|
||||
savePreset,
|
||||
loadPreset,
|
||||
deletePreset,
|
||||
} = store;
|
||||
|
||||
// Local state
|
||||
const showSavePresetDialog = ref(false);
|
||||
const isMobile = ref(false);
|
||||
|
||||
// Computed
|
||||
const sortingOptions = computed(() => [
|
||||
{ value: "name", label: "Name (A-Z)" },
|
||||
{ value: "-name", label: "Name (Z-A)" },
|
||||
{ value: "opening_date", label: "Oldest First" },
|
||||
{ value: "-opening_date", label: "Newest First" },
|
||||
{ value: "-height", label: "Tallest First" },
|
||||
{ value: "height", label: "Shortest First" },
|
||||
{ value: "-speed", label: "Fastest First" },
|
||||
{ value: "speed", label: "Slowest First" },
|
||||
{ value: "-rating", label: "Highest Rated" },
|
||||
{ value: "rating", label: "Lowest Rated" },
|
||||
]);
|
||||
|
||||
const pageSizeOptions = computed(() => [
|
||||
{ value: 10, label: "10 per page" },
|
||||
{ value: 25, label: "25 per page" },
|
||||
{ value: 50, label: "50 per page" },
|
||||
{ value: 100, label: "100 per page" },
|
||||
]);
|
||||
|
||||
// Methods
|
||||
const handleSavePreset = (name: string) => {
|
||||
savePreset(name);
|
||||
showSavePresetDialog.value = false;
|
||||
};
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ride-filter-sidebar {
|
||||
@apply flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.filter-sections {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.active-filters {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.filter-presets {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for filter sections */
|
||||
.filter-sections::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
.filter-sections::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
|
||||
.filter-sections::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
|
||||
}
|
||||
|
||||
.filter-sections::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-500;
|
||||
}
|
||||
</style>
|
||||
401
frontend/src/components/filters/SavePresetDialog.vue
Normal file
401
frontend/src/components/filters/SavePresetDialog.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
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
|
||||
>
|
||||
<!-- 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" }}
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form @submit.prevent="handleSave" class="p-6">
|
||||
<!-- Name field -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="preset-name"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Preset Name *
|
||||
</label>
|
||||
<input
|
||||
id="preset-name"
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
maxlength="50"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors"
|
||||
:class="{
|
||||
'border-red-300 dark:border-red-600': errors.name,
|
||||
}"
|
||||
placeholder="Enter preset name..."
|
||||
@blur="validateName"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description field -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="preset-description"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="preset-description"
|
||||
v-model="formData.description"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors resize-none"
|
||||
placeholder="Optional description for this preset..."
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formData.description?.length || 0 }}/200 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scope selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Preset Scope
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="formData.scope"
|
||||
type="radio"
|
||||
value="personal"
|
||||
class="mr-2 text-blue-600"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Personal (only visible to me)
|
||||
</span>
|
||||
</label>
|
||||
<label v-if="allowGlobal" class="flex items-center">
|
||||
<input
|
||||
v-model="formData.scope"
|
||||
type="radio"
|
||||
value="global"
|
||||
class="mr-2 text-blue-600"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Global (visible to all users)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Make default checkbox -->
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="formData.isDefault"
|
||||
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>
|
||||
</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>
|
||||
<div class="space-y-1">
|
||||
<p
|
||||
v-if="filterSummary.length === 0"
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No active filters
|
||||
</p>
|
||||
<p
|
||||
v-for="filter in filterSummary"
|
||||
:key="filter.key"
|
||||
class="text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
• {{ filter.label }}: {{ filter.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
: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
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
interface Props {
|
||||
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>];
|
||||
}>();
|
||||
|
||||
// Form data
|
||||
const formData = ref({
|
||||
name: "",
|
||||
description: "",
|
||||
scope: "personal" as "personal" | "global",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
// Form state
|
||||
const errors = ref({
|
||||
name: "",
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Computed
|
||||
const isValid = computed(() => {
|
||||
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;
|
||||
|
||||
if (filters.search?.trim()) {
|
||||
summary.push({
|
||||
key: "search",
|
||||
label: "Search",
|
||||
value: filters.search,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.categories?.length) {
|
||||
summary.push({
|
||||
key: "categories",
|
||||
label: "Categories",
|
||||
value: filters.categories.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.manufacturers?.length) {
|
||||
summary.push({
|
||||
key: "manufacturers",
|
||||
label: "Manufacturers",
|
||||
value: filters.manufacturers.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.designers?.length) {
|
||||
summary.push({
|
||||
key: "designers",
|
||||
label: "Designers",
|
||||
value: filters.designers.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.parks?.length) {
|
||||
summary.push({
|
||||
key: "parks",
|
||||
label: "Parks",
|
||||
value: filters.parks.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.status?.length) {
|
||||
summary.push({
|
||||
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";
|
||||
summary.push({
|
||||
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";
|
||||
summary.push({
|
||||
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: "" },
|
||||
];
|
||||
|
||||
ranges.forEach(({ key, label, data, unit }) => {
|
||||
if (data?.min !== undefined || data?.max !== undefined) {
|
||||
const min = data.min ?? "Any";
|
||||
const max = data.max ?? "Any";
|
||||
summary.push({
|
||||
key,
|
||||
label,
|
||||
value: `${min} - ${max}${unit ? " " + unit : ""}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const validateName = () => {
|
||||
const name = formData.value.name.trim();
|
||||
errors.value.name = "";
|
||||
|
||||
if (name.length === 0) {
|
||||
errors.value.name = "Preset name is required";
|
||||
} else if (name.length < 2) {
|
||||
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";
|
||||
} 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";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
validateName();
|
||||
if (!isValid.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const preset: Partial<FilterPreset> = {
|
||||
name: formData.value.name.trim(),
|
||||
description: formData.value.description?.trim() || undefined,
|
||||
filters: props.filters,
|
||||
scope: formData.value.scope,
|
||||
isDefault: formData.value.isDefault,
|
||||
lastUsed: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (props.editMode && props.existingPreset) {
|
||||
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
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
if (props.editMode && props.existingPreset) {
|
||||
formData.value = {
|
||||
name: props.existingPreset.name,
|
||||
description: props.existingPreset.description || "",
|
||||
scope: props.existingPreset.scope || "personal",
|
||||
isDefault: props.existingPreset.isDefault || false,
|
||||
};
|
||||
} else {
|
||||
formData.value = {
|
||||
name: "",
|
||||
description: "",
|
||||
scope: "personal",
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
errors.value.name = "";
|
||||
|
||||
// Focus the name input
|
||||
await nextTick();
|
||||
const nameInput = document.getElementById("preset-name");
|
||||
if (nameInput) {
|
||||
nameInput.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => formData.value.name,
|
||||
() => {
|
||||
if (errors.value.name) {
|
||||
validateName();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any custom styles if needed */
|
||||
</style>
|
||||
339
frontend/src/components/filters/SearchFilter.vue
Normal file
339
frontend/src/components/filters/SearchFilter.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search rides, parks, manufacturers..."
|
||||
class="search-input block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white text-sm"
|
||||
@input="handleSearchInput"
|
||||
@focus="showSuggestions = true"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeydown"
|
||||
:aria-expanded="showSuggestions && suggestions.length > 0"
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="clearSearch"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Suggestions -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="showSuggestions && suggestions.length > 0"
|
||||
class="suggestions-dropdown absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
>
|
||||
<div
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="`${suggestion.type}-${suggestion.value}`"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
class="suggestion-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900': highlightedIndex === index,
|
||||
}"
|
||||
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"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ suggestion.label }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{{ suggestion.type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="suggestion.count" class="text-xs text-gray-400 ml-2">
|
||||
{{ suggestion.count }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingSuggestions" class="flex items-center justify-center py-3">
|
||||
<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>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 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="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in quickFilters"
|
||||
:key="filter.value"
|
||||
@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" />
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
// Store
|
||||
const store = useRideFilteringStore();
|
||||
const {
|
||||
searchQuery,
|
||||
searchSuggestions,
|
||||
showSuggestions: showStoreSuggestions,
|
||||
} = storeToRefs(store);
|
||||
const { setSearchQuery, showSearchSuggestions, hideSearchSuggestions } = store;
|
||||
|
||||
// Composable
|
||||
const { getSearchSuggestions } = useRideFiltering();
|
||||
|
||||
// Local state
|
||||
const searchInput = ref<HTMLInputElement>();
|
||||
const highlightedIndex = ref(-1);
|
||||
const showSuggestions = ref(false);
|
||||
const isLoadingSuggestions = ref(false);
|
||||
|
||||
// Computed
|
||||
const suggestions = computed(() => searchSuggestions.value || []);
|
||||
|
||||
const quickFilters = computed(() => [
|
||||
{
|
||||
value: "operating",
|
||||
label: "Operating",
|
||||
icon: "play",
|
||||
filter: { status: ["operating"] },
|
||||
},
|
||||
{
|
||||
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: "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;
|
||||
|
||||
setSearchQuery(query);
|
||||
|
||||
if (query.length >= 2) {
|
||||
isLoadingSuggestions.value = true;
|
||||
showSuggestions.value = true;
|
||||
|
||||
try {
|
||||
await getSearchSuggestions(query);
|
||||
} catch (error) {
|
||||
console.error("Failed to load search suggestions:", error);
|
||||
} finally {
|
||||
isLoadingSuggestions.value = false;
|
||||
}
|
||||
} else {
|
||||
showSuggestions.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay hiding suggestions to allow for clicks
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleKeydown = async (event: KeyboardEvent) => {
|
||||
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 "ArrowUp":
|
||||
event.preventDefault();
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex.value >= 0) {
|
||||
selectSuggestion(suggestions.value[highlightedIndex.value]);
|
||||
}
|
||||
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;
|
||||
|
||||
// Apply additional filters based on suggestion type
|
||||
if (suggestion.filters) {
|
||||
Object.entries(suggestion.filters).forEach(([key, value]) => {
|
||||
store.updateFilter(key as any, value);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Focus search input on mount if no active filters
|
||||
if (!store.hasActiveFilters) {
|
||||
nextTick(() => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-filter {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
@apply ring-2 ring-blue-500 border-blue-500;
|
||||
}
|
||||
|
||||
.suggestions-dropdown {
|
||||
@apply border shadow-lg;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.suggestion-item:first-child {
|
||||
@apply rounded-t-md;
|
||||
}
|
||||
|
||||
.suggestion-item:last-child {
|
||||
@apply rounded-b-md;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for suggestions */
|
||||
.suggestions-dropdown::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-500;
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/components/filters/SearchableSelect.vue
Normal file
484
frontend/src/components/filters/SearchableSelect.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<div class="searchable-select">
|
||||
<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>
|
||||
|
||||
<div class="relative">
|
||||
<!-- 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" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
@input="handleSearchInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeydown"
|
||||
type="text"
|
||||
:id="id"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="search-input block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
|
||||
:class="{
|
||||
'text-gray-400 dark:text-gray-500': !hasSelection && !searchQuery,
|
||||
}"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
autocomplete="off"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
role="combobox"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<button
|
||||
v-if="searchQuery || hasSelection"
|
||||
@click="clearAll"
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
<Icon
|
||||
v-else
|
||||
name="chevron-down"
|
||||
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ml-1"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected items display -->
|
||||
<div v-if="hasSelection && !isOpen" class="mt-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="selectedOption in selectedOptions"
|
||||
:key="selectedOption.value"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{{ selectedOption.label }}
|
||||
<button
|
||||
@click="removeOption(selectedOption.value)"
|
||||
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" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="dropdown-menu absolute z-20 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
>
|
||||
<!-- 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>
|
||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div v-else-if="filteredOptions.length > 0">
|
||||
<div
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option.value"
|
||||
@click="toggleOption(option.value)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
class="dropdown-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900': highlightedIndex === index,
|
||||
'bg-green-50 dark:bg-green-900': isSelected(option.value),
|
||||
}"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option.value)"
|
||||
>
|
||||
<div class="flex items-center mr-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(option.value)"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<span v-html="highlightSearchTerm(option.label)"></span>
|
||||
</div>
|
||||
<div
|
||||
v-if="option.description"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 truncate"
|
||||
>
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="option.count !== undefined" class="text-xs text-gray-400 ml-2">
|
||||
{{ option.count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
<div
|
||||
v-else-if="searchQuery"
|
||||
class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"
|
||||
>
|
||||
No results found for "{{ searchQuery }}"
|
||||
<button
|
||||
v-if="allowCreate"
|
||||
@click="createOption"
|
||||
class="block mt-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
>
|
||||
Create "{{ searchQuery }}"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- No options message -->
|
||||
<div
|
||||
v-else
|
||||
class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"
|
||||
>
|
||||
{{ noOptionsMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Selected count -->
|
||||
<div v-if="hasSelection" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ selectedCount }} selected
|
||||
<button
|
||||
v-if="clearable"
|
||||
@click="clearSelection"
|
||||
class="ml-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
:disabled="disabled"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
interface Option {
|
||||
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;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
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];
|
||||
}>();
|
||||
|
||||
// Local state
|
||||
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 : [];
|
||||
});
|
||||
|
||||
const selectedOptions = computed(() => {
|
||||
return props.options.filter((option) => selectedValues.value.includes(option.value));
|
||||
});
|
||||
|
||||
const hasSelection = computed(() => {
|
||||
return selectedValues.value.length > 0;
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
return selectedValues.value.length;
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value || searchQuery.value.length < props.minSearchLength) {
|
||||
return props.options;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return props.options.filter(
|
||||
(option) =>
|
||||
option.label.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;
|
||||
|
||||
if (query.length >= props.minSearchLength) {
|
||||
emit("search", query);
|
||||
}
|
||||
|
||||
if (!isOpen.value && query) {
|
||||
isOpen.value = true;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!props.disabled) {
|
||||
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;
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value) {
|
||||
if (event.key === "ArrowDown" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
isOpen.value = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
highlightedIndex.value = Math.min(
|
||||
highlightedIndex.value + 1,
|
||||
filteredOptions.value.length - 1
|
||||
);
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex.value >= 0 && filteredOptions.value[highlightedIndex.value]) {
|
||||
toggleOption(filteredOptions.value[highlightedIndex.value].value);
|
||||
} else if (props.allowCreate && searchQuery.value) {
|
||||
createOption();
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
isOpen.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
searchInput.value?.blur();
|
||||
break;
|
||||
|
||||
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);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOption = (optionValue: string | number) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
const currentValues = [...selectedValues.value];
|
||||
const index = currentValues.indexOf(optionValue);
|
||||
|
||||
if (index >= 0) {
|
||||
currentValues.splice(index, 1);
|
||||
} else {
|
||||
currentValues.push(optionValue);
|
||||
}
|
||||
|
||||
emit("update", currentValues.length > 0 ? currentValues : undefined);
|
||||
|
||||
// Clear search after selection
|
||||
searchQuery.value = "";
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const removeOption = (optionValue: string | number) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
const currentValues = [...selectedValues.value];
|
||||
const index = currentValues.indexOf(optionValue);
|
||||
|
||||
if (index >= 0) {
|
||||
currentValues.splice(index, 1);
|
||||
emit("update", currentValues.length > 0 ? currentValues : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (optionValue: string | number): boolean => {
|
||||
return selectedValues.value.includes(optionValue);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (!props.disabled) {
|
||||
emit("update", undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
searchQuery.value = "";
|
||||
if (hasSelection.value) {
|
||||
clearSelection();
|
||||
}
|
||||
searchInput.value?.focus();
|
||||
};
|
||||
|
||||
const createOption = () => {
|
||||
if (props.allowCreate && searchQuery.value.trim()) {
|
||||
emit("create", searchQuery.value.trim());
|
||||
searchQuery.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const highlightSearchTerm = (text: string): string => {
|
||||
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>');
|
||||
};
|
||||
|
||||
// Handle clicks outside
|
||||
const handleClickOutside = (event: Event) => {
|
||||
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;
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-input {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
@apply ring-2 ring-blue-500 border-blue-500;
|
||||
}
|
||||
|
||||
.search-input:disabled {
|
||||
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
@apply border shadow-lg;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.dropdown-item:first-child {
|
||||
@apply rounded-t-md;
|
||||
}
|
||||
|
||||
.dropdown-item:last-child {
|
||||
@apply rounded-b-md;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dropdown */
|
||||
.dropdown-menu::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-500;
|
||||
}
|
||||
|
||||
/* Highlight search terms */
|
||||
:deep(mark) {
|
||||
@apply bg-yellow-200 dark:bg-yellow-800 px-0;
|
||||
}
|
||||
</style>
|
||||
356
frontend/src/components/filters/SelectFilter.vue
Normal file
356
frontend/src/components/filters/SelectFilter.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="select-filter">
|
||||
<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>
|
||||
|
||||
<div class="relative">
|
||||
<select
|
||||
v-if="!multiple"
|
||||
:id="id"
|
||||
:value="value"
|
||||
@change="handleSingleChange"
|
||||
class="select-input block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
|
||||
:class="{
|
||||
'text-gray-400 dark:text-gray-500': !value,
|
||||
}"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<option value="" class="text-gray-500">
|
||||
{{ placeholder || `Select ${label.toLowerCase()}...` }}
|
||||
</option>
|
||||
<option
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Multiple select with custom UI -->
|
||||
<div v-else class="multi-select-container">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleDropdown"
|
||||
class="select-trigger flex items-center justify-between w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
|
||||
:class="{
|
||||
'text-gray-400 dark:text-gray-500': !hasSelection,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
>
|
||||
<span class="flex-1 text-left truncate">
|
||||
<span v-if="!hasSelection">
|
||||
{{ placeholder || `Select ${label.toLowerCase()}...` }}
|
||||
</span>
|
||||
<span v-else class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="selectedOption in selectedOptions"
|
||||
:key="selectedOption.value"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{{ selectedOption.label }}
|
||||
<button
|
||||
@click.stop="removeOption(selectedOption.value)"
|
||||
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" />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="dropdown-menu absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
>
|
||||
<div
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
@click="toggleOption(option.value)"
|
||||
class="dropdown-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900': isSelected(option.value),
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center mr-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(option.value)"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<span class="flex-1 text-sm text-gray-900 dark:text-white">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="option.count !== undefined"
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ option.count }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="normalizedOptions.length === 0"
|
||||
class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No options available
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected count indicator for multiple -->
|
||||
<div
|
||||
v-if="multiple && hasSelection"
|
||||
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ selectedCount }} selected
|
||||
</div>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
v-if="clearable && hasSelection"
|
||||
@click="clearSelection"
|
||||
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" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import Icon from "@/components/ui/Icon.vue";
|
||||
|
||||
interface Option {
|
||||
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;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
required: false,
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [value: string | number | (string | number)[] | undefined];
|
||||
}>();
|
||||
|
||||
// Local state
|
||||
const isOpen = ref(false);
|
||||
|
||||
// Computed
|
||||
const normalizedOptions = computed((): Option[] => {
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === "string" || typeof option === "number") {
|
||||
return {
|
||||
value: option,
|
||||
label: String(option),
|
||||
};
|
||||
}
|
||||
return option;
|
||||
});
|
||||
});
|
||||
|
||||
const selectedValues = computed(() => {
|
||||
if (!props.multiple) {
|
||||
return props.value ? [props.value] : [];
|
||||
}
|
||||
return Array.isArray(props.value) ? props.value : [];
|
||||
});
|
||||
|
||||
const selectedOptions = computed(() => {
|
||||
return normalizedOptions.value.filter((option) =>
|
||||
selectedValues.value.includes(option.value)
|
||||
);
|
||||
});
|
||||
|
||||
const hasSelection = computed(() => {
|
||||
return selectedValues.value.length > 0;
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
return selectedValues.value.length;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleSingleChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
const value = target.value;
|
||||
emit("update", value || undefined);
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOption = (optionValue: string | number) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
if (!props.multiple) {
|
||||
emit("update", optionValue);
|
||||
isOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValues = Array.isArray(props.value) ? [...props.value] : [];
|
||||
const index = currentValues.indexOf(optionValue);
|
||||
|
||||
if (index >= 0) {
|
||||
currentValues.splice(index, 1);
|
||||
} else {
|
||||
currentValues.push(optionValue);
|
||||
}
|
||||
|
||||
emit("update", currentValues.length > 0 ? currentValues : undefined);
|
||||
};
|
||||
|
||||
const removeOption = (optionValue: string | number) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
if (!props.multiple) {
|
||||
emit("update", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (optionValue: string | number): boolean => {
|
||||
return selectedValues.value.includes(optionValue);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (!props.disabled) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-input {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
@apply ring-2 ring-blue-500 border-blue-500;
|
||||
}
|
||||
|
||||
.select-input:disabled {
|
||||
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.select-trigger:focus {
|
||||
@apply ring-2 ring-blue-500 border-blue-500;
|
||||
}
|
||||
|
||||
.select-trigger:disabled {
|
||||
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
@apply border shadow-lg;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.dropdown-item:first-child {
|
||||
@apply rounded-t-md;
|
||||
}
|
||||
|
||||
.dropdown-item:last-child {
|
||||
@apply rounded-b-md;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dropdown */
|
||||
.dropdown-menu::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-500;
|
||||
}
|
||||
</style>
|
||||
289
frontend/src/components/rides/RideCard.vue
Normal file
289
frontend/src/components/rides/RideCard.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<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"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- Ride Image -->
|
||||
<div class="aspect-w-16 aspect-h-9 bg-gray-100 dark:bg-gray-700">
|
||||
<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"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-48 flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600"
|
||||
>
|
||||
<Icon name="camera" class="w-12 h-12 text-white opacity-60" />
|
||||
</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),
|
||||
]"
|
||||
>
|
||||
{{ getStatusDisplay(ride.status) }}
|
||||
</span>
|
||||
</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>
|
||||
</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) }}
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- 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
|
||||
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"
|
||||
>
|
||||
{{ ride.inversions }} inversion{{ ride.inversions !== 1 ? "s" : "" }}
|
||||
</span>
|
||||
<span
|
||||
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"
|
||||
>
|
||||
{{ ride.launch_type }} launch
|
||||
</span>
|
||||
<span
|
||||
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"
|
||||
>
|
||||
{{ ride.track_material }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-5 transition-all duration-200 pointer-events-none"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { Ride } from "@/types";
|
||||
import Icon from "@/components/ui/Icon.vue";
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
ride: Ride;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Emits
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
// Computed properties
|
||||
const hasSpecialFeatures = computed(() => {
|
||||
return !!(
|
||||
(props.ride.inversions && props.ride.inversions > 0) ||
|
||||
props.ride.launch_type ||
|
||||
props.ride.track_material
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const getStatusColor = (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";
|
||||
default:
|
||||
return "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
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";
|
||||
default:
|
||||
return status || "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (remainingSeconds === 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const formatCapacity = (capacity: number) => {
|
||||
if (capacity >= 1000) {
|
||||
return `${(capacity / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return capacity.toString();
|
||||
};
|
||||
|
||||
const formatYear = (dateString: string) => {
|
||||
return new Date(dateString).getFullYear();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aspect-w-16 {
|
||||
position: relative;
|
||||
padding-bottom: calc(9 / 16 * 100%);
|
||||
}
|
||||
|
||||
.aspect-w-16 > * {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
516
frontend/src/components/rides/RideListDisplay.vue
Normal file
516
frontend/src/components/rides/RideListDisplay.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="ride-list-display">
|
||||
<!-- 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" />
|
||||
<span class="text-gray-600 dark:text-gray-300">Loading rides...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
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>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-red-700 dark:text-red-300">{{ error }}</p>
|
||||
<button
|
||||
@click="retrySearch"
|
||||
class="mt-3 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-500 focus:outline-none focus:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results Header -->
|
||||
<div v-else-if="rides.length > 0 || hasActiveFilters" class="space-y-4">
|
||||
<!-- Results Count and Sort -->
|
||||
<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 v-if="hasActiveFilters" class="text-gray-500 dark:text-gray-400">
|
||||
with active filters
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Active Filters Count -->
|
||||
<span
|
||||
v-if="activeFilterCount > 0"
|
||||
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
|
||||
</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"
|
||||
>
|
||||
Sort by:
|
||||
</label>
|
||||
<select
|
||||
id="sort-select"
|
||||
v-model="currentSort"
|
||||
@change="handleSortChange"
|
||||
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="name">Name (A-Z)</option>
|
||||
<option value="-name">Name (Z-A)</option>
|
||||
<option value="-opening_date">Newest First</option>
|
||||
<option value="opening_date">Oldest First</option>
|
||||
<option value="-average_rating">Highest Rated</option>
|
||||
<option value="average_rating">Lowest Rated</option>
|
||||
<option value="-height_ft">Tallest First</option>
|
||||
<option value="height_ft">Shortest First</option>
|
||||
<option value="-speed_mph">Fastest First</option>
|
||||
<option value="speed_mph">Slowest First</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
>
|
||||
<ActiveFilterChip
|
||||
v-for="filter in activeFilters"
|
||||
:key="filter.key"
|
||||
:label="filter.label"
|
||||
:value="filter.value"
|
||||
@remove="removeFilter(filter)"
|
||||
/>
|
||||
<button
|
||||
@click="clearAllFilters"
|
||||
class="ml-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-500 focus:outline-none focus:underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ride Grid -->
|
||||
<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"
|
||||
:ride="ride"
|
||||
@click="handleRideClick(ride)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
No rides match your current search criteria.
|
||||
</p>
|
||||
<button
|
||||
@click="clearAllFilters"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing <span class="font-medium">{{ startItem }}</span> to
|
||||
<span class="font-medium">{{ endItem }}</span> of{' '}
|
||||
<span class="font-medium">{{ totalCount }}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
: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" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
@click="goToPage(page)"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 dark:bg-blue-900/30 border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700',
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
: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" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Use the search and filters to discover rides that match your interests.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
parkSlug?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parkSlug: undefined,
|
||||
});
|
||||
|
||||
// Composables
|
||||
const router = useRouter();
|
||||
const rideFilteringStore = useRideFilteringStore();
|
||||
|
||||
// Store state
|
||||
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 });
|
||||
},
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
const f = filters.value;
|
||||
return !!(
|
||||
f.search ||
|
||||
f.categories.length > 0 ||
|
||||
f.manufacturers.length > 0 ||
|
||||
f.designers.length > 0 ||
|
||||
f.parks.length > 0 ||
|
||||
f.status.length > 0 ||
|
||||
f.heightRange[0] > 0 ||
|
||||
f.heightRange[1] < 500 ||
|
||||
f.speedRange[0] > 0 ||
|
||||
f.speedRange[1] < 200 ||
|
||||
f.capacityRange[0] > 0 ||
|
||||
f.capacityRange[1] < 10000 ||
|
||||
f.durationRange[0] > 0 ||
|
||||
f.durationRange[1] < 600 ||
|
||||
f.openingDateRange[0] ||
|
||||
f.openingDateRange[1] ||
|
||||
f.closingDateRange[0] ||
|
||||
f.closingDateRange[1]
|
||||
);
|
||||
});
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
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++;
|
||||
|
||||
return count;
|
||||
});
|
||||
|
||||
const activeFilters = computed(() => {
|
||||
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 });
|
||||
}
|
||||
|
||||
if (f.categories.length > 0) {
|
||||
active.push({
|
||||
key: "categories",
|
||||
label: "Categories",
|
||||
value: `${f.categories.length} selected`,
|
||||
});
|
||||
}
|
||||
|
||||
if (f.manufacturers.length > 0) {
|
||||
active.push({
|
||||
key: "manufacturers",
|
||||
label: "Manufacturers",
|
||||
value: `${f.manufacturers.length} selected`,
|
||||
});
|
||||
}
|
||||
|
||||
if (f.designers.length > 0) {
|
||||
active.push({
|
||||
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` });
|
||||
}
|
||||
|
||||
if (f.status.length > 0) {
|
||||
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",
|
||||
value: `${f.heightRange[0]}-${f.heightRange[1]} ft`,
|
||||
});
|
||||
}
|
||||
|
||||
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) {
|
||||
active.push({
|
||||
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",
|
||||
value: `${f.capacityRange[0]}-${f.capacityRange[1]}/hr`,
|
||||
});
|
||||
}
|
||||
|
||||
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) {
|
||||
active.push({
|
||||
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}` });
|
||||
}
|
||||
|
||||
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}` });
|
||||
}
|
||||
|
||||
return active;
|
||||
});
|
||||
|
||||
const startItem = computed(() => {
|
||||
return (currentPage.value - 1) * 20 + 1;
|
||||
});
|
||||
|
||||
const endItem = computed(() => {
|
||||
return Math.min(currentPage.value * 20, totalCount.value);
|
||||
});
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = totalPages.value;
|
||||
const current = currentPage.value;
|
||||
const pages: number[] = [];
|
||||
|
||||
// Always show first page
|
||||
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);
|
||||
|
||||
// Add ellipsis if there's a gap
|
||||
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);
|
||||
}
|
||||
|
||||
// Add ellipsis if there's a gap
|
||||
if (end < total - 1) pages.push(-1);
|
||||
|
||||
// Always show last page
|
||||
if (total > 1) pages.push(total);
|
||||
|
||||
return pages;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleSortChange = () => {
|
||||
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
|
||||
};
|
||||
|
||||
const removeFilter = (filter: { key: string; label: string; value: string }) => {
|
||||
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;
|
||||
}
|
||||
|
||||
rideFilteringStore.updateFilters(updates);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
rideFilteringStore.resetFilters();
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
rideFilteringStore.updateFilters({ page });
|
||||
}
|
||||
};
|
||||
|
||||
const retrySearch = () => {
|
||||
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
|
||||
};
|
||||
|
||||
const handleRideClick = (ride: Ride) => {
|
||||
if (props.parkSlug) {
|
||||
router.push(`/parks/${props.parkSlug}/rides/${ride.slug}`);
|
||||
} else {
|
||||
router.push(`/rides/${ride.slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for filter changes
|
||||
watch(
|
||||
() => filters.value,
|
||||
() => {
|
||||
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Initialize search on mount
|
||||
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
|
||||
</script>
|
||||
505
frontend/src/components/ui/Icon.vue
Normal file
505
frontend/src/components/ui/Icon.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<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>
|
||||
379
frontend/src/composables/useRideFiltering.ts
Normal file
379
frontend/src/composables/useRideFiltering.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Composable for ride filtering API communication
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, type Ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
import type {
|
||||
Ride,
|
||||
RideFilters,
|
||||
FilterOptions,
|
||||
CompanySearchResult,
|
||||
RideModelSearchResult,
|
||||
SearchSuggestion,
|
||||
ApiResponse
|
||||
} from '@/types'
|
||||
|
||||
export function useRideFiltering(initialFilters: RideFilters = {}) {
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const rides = ref<Ride[]>([])
|
||||
const totalCount = ref(0)
|
||||
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 => {
|
||||
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(',')
|
||||
}
|
||||
} else {
|
||||
params[key] = String(value)
|
||||
}
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch rides with current filters
|
||||
*/
|
||||
const fetchRides = async (resetPagination = true) => {
|
||||
if (resetPagination) {
|
||||
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)
|
||||
|
||||
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'
|
||||
console.error('Error fetching rides:', err)
|
||||
throw err
|
||||
} finally {
|
||||
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)
|
||||
|
||||
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'
|
||||
console.error('Error loading more rides:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch filter options from API
|
||||
*/
|
||||
const fetchFilterOptions = async () => {
|
||||
try {
|
||||
const response = await api.client.get<FilterOptions>('/rides/api/filter-options/')
|
||||
filterOptions.value = response
|
||||
return response
|
||||
} catch (err) {
|
||||
console.error('Error fetching filter options:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search companies for manufacturer/designer autocomplete
|
||||
*/
|
||||
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)
|
||||
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 })
|
||||
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 })
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple filters at once
|
||||
*/
|
||||
const updateFilters = (newFilters: Partial<RideFilters>) => {
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
...newFilters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
const clearFilters = () => {
|
||||
filters.value = {}
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific filter
|
||||
*/
|
||||
const clearFilter = (key: keyof RideFilters) => {
|
||||
const newFilters = { ...filters.value }
|
||||
delete newFilters[key]
|
||||
filters.value = newFilters
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced search for text inputs
|
||||
*/
|
||||
const debouncedSearch = (query: string, delay = 500) => {
|
||||
if (searchDebounceTimeout.value) {
|
||||
clearTimeout(searchDebounceTimeout.value)
|
||||
}
|
||||
|
||||
searchDebounceTimeout.value = window.setTimeout(() => {
|
||||
updateFilter('search', query)
|
||||
fetchRides()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sorting
|
||||
*/
|
||||
const setSorting = (ordering: string) => {
|
||||
updateFilter('ordering', ordering)
|
||||
fetchRides()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set page size
|
||||
*/
|
||||
const setPageSize = (pageSize: number) => {
|
||||
updateFilter('page_size', pageSize)
|
||||
fetchRides()
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current results
|
||||
*/
|
||||
const exportResults = async (format: 'csv' | 'json' = 'csv') => {
|
||||
try {
|
||||
const queryParams = buildQueryParams({
|
||||
...filters.value,
|
||||
export: format,
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Export failed')
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `rides_export.${format}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error('Error exporting results:', err)
|
||||
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 }
|
||||
)
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
rides,
|
||||
totalCount,
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
filters,
|
||||
filterOptions,
|
||||
|
||||
// Computed
|
||||
hasActiveFilters,
|
||||
isFirstLoad,
|
||||
|
||||
// Methods
|
||||
fetchRides,
|
||||
loadMore,
|
||||
fetchFilterOptions,
|
||||
searchCompanies,
|
||||
searchRideModels,
|
||||
getSearchSuggestions,
|
||||
updateFilter,
|
||||
updateFilters,
|
||||
clearFilters,
|
||||
clearFilter,
|
||||
debouncedSearch,
|
||||
setSorting,
|
||||
setPageSize,
|
||||
exportResults,
|
||||
|
||||
// Utilities
|
||||
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)
|
||||
|
||||
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'
|
||||
console.error('Error fetching park rides:', err)
|
||||
throw err
|
||||
} finally {
|
||||
baseComposable.isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseComposable,
|
||||
fetchRides
|
||||
}
|
||||
}
|
||||
100
frontend/src/composables/useTheme.ts
Normal file
100
frontend/src/composables/useTheme.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
// Theme state
|
||||
const isDark = ref(false)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
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') => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
}
|
||||
@@ -607,6 +607,57 @@ export class RidesApi {
|
||||
rides: Ride[]
|
||||
}>('/api/rides/recent_relocations/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filter options for ride filtering
|
||||
*/
|
||||
async getFilterOptions(): Promise<any> {
|
||||
return this.client.get('/rides/api/filter-options/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Search companies for manufacturer/designer autocomplete
|
||||
*/
|
||||
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/api/search-companies/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search ride models for autocomplete
|
||||
*/
|
||||
async searchRideModels(query: string): Promise<any[]> {
|
||||
return this.client.get('/rides/api/search-ride-models/', { q: query })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search suggestions
|
||||
*/
|
||||
async getSearchSuggestions(query: string): Promise<any[]> {
|
||||
return this.client.get('/rides/api/search-suggestions/', { q: query })
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced ride filtering with comprehensive options
|
||||
*/
|
||||
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(',')
|
||||
}
|
||||
} else {
|
||||
params[key] = String(value)
|
||||
}
|
||||
})
|
||||
|
||||
return this.client.get<ApiResponse<Ride>>('/rides/api/', params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
441
frontend/src/stores/rideFiltering.ts
Normal file
441
frontend/src/stores/rideFiltering.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Pinia store for ride filtering state management
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type {
|
||||
RideFilters,
|
||||
FilterOptions,
|
||||
ActiveFilter,
|
||||
FilterFormState,
|
||||
Ride
|
||||
} from '@/types'
|
||||
import { useRideFiltering } from '@/composables/useRideFiltering'
|
||||
|
||||
export const useRideFilteringStore = defineStore('rideFiltering', () => {
|
||||
// Core state
|
||||
const filters = ref<RideFilters>({})
|
||||
const filterOptions = ref<FilterOptions | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// UI state
|
||||
const formState = ref<FilterFormState>({
|
||||
isOpen: false,
|
||||
expandedSections: {
|
||||
search: true,
|
||||
basic: true,
|
||||
manufacturer: false,
|
||||
specifications: false,
|
||||
dates: false,
|
||||
location: false,
|
||||
advanced: false
|
||||
},
|
||||
hasChanges: false,
|
||||
appliedFilters: {},
|
||||
pendingFilters: {}
|
||||
})
|
||||
|
||||
// Results state
|
||||
const rides = ref<Ride[]>([])
|
||||
const totalCount = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const hasNextPage = ref(false)
|
||||
const hasPreviousPage = ref(false)
|
||||
|
||||
// Search state
|
||||
const searchQuery = ref('')
|
||||
const searchSuggestions = ref<any[]>([])
|
||||
const showSuggestions = ref(false)
|
||||
|
||||
// Filter presets
|
||||
const savedPresets = ref<any[]>([])
|
||||
const currentPreset = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const hasActiveFilters = computed(() => {
|
||||
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]) => {
|
||||
if (key === 'page' || key === 'page_size' || key === 'ordering') return
|
||||
if (Array.isArray(value) && value.length > 0) count++
|
||||
else if (value !== undefined && value !== null && value !== '') count++
|
||||
})
|
||||
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'
|
||||
})
|
||||
} 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')) {
|
||||
category = 'range'
|
||||
displayValue = formatRangeValue(key, value)
|
||||
} else if (key.includes('date')) {
|
||||
category = 'date'
|
||||
displayValue = formatDateValue(value)
|
||||
}
|
||||
|
||||
list.push({
|
||||
key,
|
||||
label: getFilterLabel(key),
|
||||
value,
|
||||
displayValue,
|
||||
category
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const isFilterFormOpen = computed(() => formState.value.isOpen)
|
||||
|
||||
const hasUnsavedChanges = computed(() => formState.value.hasChanges)
|
||||
|
||||
// Helper functions
|
||||
const getFilterLabel = (key: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
search: 'Search',
|
||||
category: 'Category',
|
||||
status: 'Status',
|
||||
manufacturer: 'Manufacturer',
|
||||
designer: 'Designer',
|
||||
park: 'Park',
|
||||
country: 'Country',
|
||||
region: 'Region',
|
||||
height_min: 'Min Height',
|
||||
height_max: 'Max Height',
|
||||
speed_min: 'Min Speed',
|
||||
speed_max: 'Max Speed',
|
||||
length_min: 'Min Length',
|
||||
length_max: 'Max Length',
|
||||
capacity_min: 'Min Capacity',
|
||||
capacity_max: 'Max Capacity',
|
||||
duration_min: 'Min Duration',
|
||||
duration_max: 'Max Duration',
|
||||
inversions_min: 'Min Inversions',
|
||||
inversions_max: 'Max Inversions',
|
||||
opening_date_from: 'Opened After',
|
||||
opening_date_to: 'Opened Before',
|
||||
closing_date_from: 'Closed After',
|
||||
closing_date_to: 'Closed Before',
|
||||
ordering: 'Sort By'
|
||||
}
|
||||
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`
|
||||
if (key.includes('speed')) return `${numValue} km/h`
|
||||
if (key.includes('length')) return `${numValue}m`
|
||||
if (key.includes('duration')) return `${numValue}s`
|
||||
if (key.includes('capacity')) return `${numValue} people`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const formatDateValue = (value: any): string => {
|
||||
if (typeof value === 'string') {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Actions
|
||||
const updateFilter = (key: keyof RideFilters, value: any) => {
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
[key]: value
|
||||
}
|
||||
formState.value.hasChanges = true
|
||||
}
|
||||
|
||||
const updateMultipleFilters = (newFilters: Partial<RideFilters>) => {
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
...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 => {
|
||||
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 => {
|
||||
formState.value.expandedSections[key] = true
|
||||
})
|
||||
}
|
||||
|
||||
const collapseAllSections = () => {
|
||||
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
|
||||
totalCount: number
|
||||
}) => {
|
||||
hasNextPage.value = pagination.hasNext
|
||||
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()
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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')
|
||||
if (stored) {
|
||||
savedPresets.value = JSON.parse(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load filter presets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize presets from localStorage
|
||||
loadPresetsFromStorage()
|
||||
|
||||
return {
|
||||
// State
|
||||
filters,
|
||||
filterOptions,
|
||||
isLoading,
|
||||
error,
|
||||
formState,
|
||||
rides,
|
||||
totalCount,
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
searchQuery,
|
||||
searchSuggestions,
|
||||
showSuggestions,
|
||||
savedPresets,
|
||||
currentPreset,
|
||||
|
||||
// Computed
|
||||
hasActiveFilters,
|
||||
activeFiltersCount,
|
||||
activeFiltersList,
|
||||
isFilterFormOpen,
|
||||
hasUnsavedChanges,
|
||||
|
||||
// Actions
|
||||
updateFilter,
|
||||
updateMultipleFilters,
|
||||
clearFilter,
|
||||
clearAllFilters,
|
||||
applyFilters,
|
||||
resetFilters,
|
||||
toggleFilterForm,
|
||||
openFilterForm,
|
||||
closeFilterForm,
|
||||
toggleSection,
|
||||
expandSection,
|
||||
collapseSection,
|
||||
expandAllSections,
|
||||
collapseAllSections,
|
||||
setSearchQuery,
|
||||
setSorting,
|
||||
setPageSize,
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setRides,
|
||||
appendRides,
|
||||
setTotalCount,
|
||||
setPagination,
|
||||
setLoading,
|
||||
setError,
|
||||
setFilterOptions,
|
||||
setSearchSuggestions,
|
||||
showSearchSuggestions,
|
||||
hideSearchSuggestions,
|
||||
savePreset,
|
||||
loadPreset,
|
||||
deletePreset,
|
||||
loadPresetsFromStorage
|
||||
}
|
||||
})
|
||||
172
frontend/src/types/filters.ts
Normal file
172
frontend/src/types/filters.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Enhanced filter types for comprehensive ride filtering system
|
||||
*/
|
||||
|
||||
// Enhanced ride filter types based on backend API design
|
||||
export interface RideFilters {
|
||||
// Search and basic filters
|
||||
search?: string
|
||||
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
|
||||
speed_min?: number
|
||||
speed_max?: number
|
||||
length_min?: number
|
||||
length_max?: number
|
||||
capacity_min?: number
|
||||
capacity_max?: number
|
||||
duration_min?: number
|
||||
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
|
||||
}
|
||||
|
||||
// Filter options from backend
|
||||
export interface FilterOptions {
|
||||
categories: FilterChoice[]
|
||||
statuses: FilterChoice[]
|
||||
manufacturers: FilterChoice[]
|
||||
designers: FilterChoice[]
|
||||
countries: FilterChoice[]
|
||||
regions: FilterChoice[]
|
||||
parks: FilterChoice[]
|
||||
ordering_options: OrderingChoice[]
|
||||
numeric_ranges: NumericRanges
|
||||
}
|
||||
|
||||
export interface FilterChoice {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface OrderingChoice {
|
||||
value: string
|
||||
label: string
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface NumericRanges {
|
||||
height: { min: number; max: number }
|
||||
speed: { min: number; max: number }
|
||||
length: { min: number; max: number }
|
||||
capacity: { min: number; max: number }
|
||||
duration: { min: number; max: number }
|
||||
inversions: { min: number; max: number }
|
||||
}
|
||||
|
||||
// Active filter display
|
||||
export interface ActiveFilter {
|
||||
key: string
|
||||
label: string
|
||||
value: string | number
|
||||
displayValue: string
|
||||
category: 'search' | 'select' | 'range' | 'date'
|
||||
}
|
||||
|
||||
// Filter form state
|
||||
export interface FilterFormState {
|
||||
isOpen: boolean
|
||||
expandedSections: Record<string, boolean>
|
||||
hasChanges: boolean
|
||||
appliedFilters: RideFilters
|
||||
pendingFilters: RideFilters
|
||||
}
|
||||
|
||||
// Search suggestions
|
||||
export interface SearchSuggestion {
|
||||
text: string
|
||||
type: 'ride' | 'park' | 'manufacturer' | 'designer'
|
||||
context?: string
|
||||
}
|
||||
|
||||
// Company search for manufacturer/designer autocomplete
|
||||
export interface CompanySearchResult {
|
||||
id: number
|
||||
name: string
|
||||
role: 'manufacturer' | 'designer' | 'both'
|
||||
ride_count: number
|
||||
}
|
||||
|
||||
// Ride model search for autocomplete
|
||||
export interface RideModelSearchResult {
|
||||
manufacturer: string
|
||||
model: string
|
||||
ride_count: number
|
||||
}
|
||||
|
||||
// Filter section configuration
|
||||
export interface FilterSection {
|
||||
id: string
|
||||
title: string
|
||||
icon?: string
|
||||
defaultExpanded: boolean
|
||||
order: number
|
||||
}
|
||||
|
||||
// Range filter configuration
|
||||
export interface RangeFilterConfig {
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
unit?: string
|
||||
format?: (value: number) => string
|
||||
}
|
||||
|
||||
// Date range filter configuration
|
||||
export interface DateRangeConfig {
|
||||
minDate?: string
|
||||
maxDate?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// Filter validation
|
||||
export interface FilterValidation {
|
||||
isValid: boolean
|
||||
errors: Record<string, string[]>
|
||||
}
|
||||
|
||||
// Filter persistence
|
||||
export interface FilterPreset {
|
||||
id: string
|
||||
name: string
|
||||
filters: RideFilters
|
||||
isDefault?: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Filter analytics
|
||||
export interface FilterStats {
|
||||
totalResults: number
|
||||
filterBreakdown: Record<string, number>
|
||||
popularFilters: Array<{
|
||||
filter: string
|
||||
usage: number
|
||||
}>
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export interface Ride {
|
||||
updated: string
|
||||
}
|
||||
|
||||
// Search and filter types
|
||||
// Search and filter types - Basic legacy interface (keeping for compatibility)
|
||||
export interface SearchFilters {
|
||||
query?: string
|
||||
category?: string
|
||||
@@ -67,6 +67,26 @@ export interface SearchFilters {
|
||||
maxSpeed?: number
|
||||
}
|
||||
|
||||
// Import comprehensive filter types
|
||||
export type {
|
||||
RideFilters,
|
||||
FilterOptions,
|
||||
FilterChoice,
|
||||
OrderingChoice,
|
||||
NumericRanges,
|
||||
ActiveFilter,
|
||||
FilterFormState,
|
||||
SearchSuggestion,
|
||||
CompanySearchResult,
|
||||
RideModelSearchResult,
|
||||
FilterSection,
|
||||
RangeFilterConfig,
|
||||
DateRangeConfig,
|
||||
FilterValidation,
|
||||
FilterPreset,
|
||||
FilterStats
|
||||
} from './filters'
|
||||
|
||||
export interface ParkFilters {
|
||||
query?: string
|
||||
status?: string
|
||||
|
||||
375
frontend/src/views/RideFilteringPage.vue
Normal file
375
frontend/src/views/RideFilteringPage.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40"
|
||||
>
|
||||
<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 -->
|
||||
<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"
|
||||
>
|
||||
<Icon name="home" class="w-5 h-5" />
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-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"
|
||||
>
|
||||
{{ 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"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-900 dark:text-gray-100 font-medium">
|
||||
{{ pageTitle }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Actions -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 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"
|
||||
>
|
||||
<Icon name="filter" class="w-4 h-4 mr-2" />
|
||||
Filters
|
||||
<span
|
||||
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>
|
||||
|
||||
<!-- 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"
|
||||
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
>
|
||||
<Icon :name="isDark ? 'sun' : 'moon'" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="lg:grid lg:grid-cols-5 lg:gap-8">
|
||||
<!-- Filter sidebar -->
|
||||
<aside class="lg:col-span-1">
|
||||
<!-- 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-y-0 left-0 w-80 bg-white dark:bg-gray-800 shadow-xl overflow-y-auto"
|
||||
>
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Filters
|
||||
</h2>
|
||||
<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"
|
||||
>
|
||||
<Icon name="x" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<RideFilterSidebar :park-slug="parkSlug" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg">
|
||||
{{ 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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride list -->
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="p-6">
|
||||
<RideListDisplay :park-slug="parkSlug" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
parkSlug?: string;
|
||||
parkName?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parkSlug: undefined,
|
||||
parkName: undefined,
|
||||
});
|
||||
|
||||
// Composables
|
||||
const route = useRoute();
|
||||
const rideFilteringStore = useRideFilteringStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
// Store state
|
||||
const { totalCount, rides, filters } = storeToRefs(rideFilteringStore);
|
||||
|
||||
// Reactive state
|
||||
const showMobileFilters = ref(false);
|
||||
|
||||
// Computed properties
|
||||
const pageTitle = computed(() => {
|
||||
if (props.parkSlug) {
|
||||
return `${props.parkName || "Park"} 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.`;
|
||||
}
|
||||
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;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const averageRating = computed(() => {
|
||||
if (!rides.value.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 operatingCount = computed(() => {
|
||||
return rides.value.filter((ride) => ride.status?.toLowerCase() === "operating").length;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const toggleMobileFilters = () => {
|
||||
showMobileFilters.value = !showMobileFilters.value;
|
||||
};
|
||||
|
||||
const closeMobileFilters = () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar for filter sidebar */
|
||||
.mobile-filter-sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.mobile-filter-sidebar::-webkit-scrollbar-track {
|
||||
background: theme("colors.gray.100");
|
||||
}
|
||||
|
||||
.mobile-filter-sidebar::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.gray.400");
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.gray.500");
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .mobile-filter-sidebar::-webkit-scrollbar-track {
|
||||
background: theme("colors.gray.700");
|
||||
}
|
||||
|
||||
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.gray.500");
|
||||
}
|
||||
|
||||
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.gray.400");
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user