feat: major API restructure and Vue.js frontend integration

- Centralize API endpoints in dedicated api app with v1 versioning
- Remove individual API modules from parks and rides apps
- Add event tracking system with analytics functionality
- Integrate Vue.js frontend with Tailwind CSS v4 and TypeScript
- Add comprehensive database migrations for event tracking
- Implement user authentication and social provider setup
- Add API schema documentation and serializers
- Configure development environment with shared scripts
- Update project structure for monorepo with frontend/backend separation
This commit is contained in:
pacnpal
2025-08-24 16:42:20 -04:00
parent 92f4104d7a
commit e62646bcf9
127 changed files with 27734 additions and 1867 deletions

View File

@@ -0,0 +1,363 @@
<template>
<div :class="containerClasses">
<!-- Button variant -->
<button
v-if="variant === 'button'"
type="button"
:class="buttonClasses"
@click="toggleTheme"
:aria-label="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
>
<!-- Sun icon (light mode) -->
<svg
v-if="currentTheme === 'dark'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<!-- Moon icon (dark mode) -->
<svg v-else :class="iconClasses" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<span v-if="showText" :class="textClasses">
{{ currentTheme === 'dark' ? 'Light' : 'Dark' }}
</span>
</button>
<!-- Dropdown variant -->
<div v-else-if="variant === 'dropdown'" class="relative">
<button
type="button"
:class="dropdownButtonClasses"
@click="dropdownOpen = !dropdownOpen"
@blur="handleBlur"
:aria-expanded="dropdownOpen"
aria-haspopup="true"
>
<!-- Current theme icon -->
<svg
v-if="currentTheme === 'dark'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<svg
v-else-if="currentTheme === 'light'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg v-else :class="iconClasses" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span v-if="showText" :class="textClasses">
{{ currentTheme === 'dark' ? 'Dark' : currentTheme === 'light' ? 'Light' : 'Auto' }}
</span>
<!-- Dropdown arrow -->
<svg
v-if="showDropdown"
class="ml-2 h-4 w-4 transition-transform"
:class="{ 'rotate-180': dropdownOpen }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Dropdown menu -->
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="dropdownOpen"
:class="dropdownMenuClasses"
role="menu"
aria-orientation="vertical"
>
<!-- Light mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'light')"
@click="setTheme('light')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
Light
</button>
<!-- Dark mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'dark')"
@click="setTheme('dark')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
Dark
</button>
<!-- System mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'system')"
@click="setTheme('system')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
System
</button>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
interface ThemeControllerProps {
variant?: 'button' | 'dropdown'
size?: 'sm' | 'md' | 'lg'
showText?: boolean
showDropdown?: boolean
position?: 'fixed' | 'relative'
}
const props = withDefaults(defineProps<ThemeControllerProps>(), {
variant: 'button',
size: 'md',
showText: false,
showDropdown: true,
position: 'relative',
})
// State
const currentTheme = ref<'light' | 'dark' | 'system'>('system')
const dropdownOpen = ref(false)
// Computed classes
const containerClasses = computed(() => {
return props.position === 'fixed' ? 'fixed top-4 right-4 z-50' : 'relative'
})
const sizeClasses = computed(() => {
const sizes = {
sm: {
button: 'h-8 px-2',
icon: 'h-4 w-4',
text: 'text-sm',
},
md: {
button: 'h-10 px-3',
icon: 'h-5 w-5',
text: 'text-sm',
},
lg: {
button: 'h-12 px-4',
icon: 'h-6 w-6',
text: 'text-base',
},
}
return sizes[props.size]
})
const buttonClasses = computed(() => {
return [
'inline-flex items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors',
'hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
sizeClasses.value.button,
].join(' ')
})
const dropdownButtonClasses = computed(() => {
return [
'inline-flex items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors',
'hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
sizeClasses.value.button,
].join(' ')
})
const dropdownMenuClasses = computed(() => {
return [
'absolute right-0 mt-2 w-48 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5',
'dark:bg-gray-800 dark:ring-gray-700',
'focus:outline-none z-50',
].join(' ')
})
const iconClasses = computed(() => {
let classes = sizeClasses.value.icon
if (props.showText) classes += ' mr-2'
return classes
})
const textClasses = computed(() => {
return `${sizeClasses.value.text} font-medium`
})
// Dropdown item classes
const dropdownItemClasses = (isActive: boolean) => {
const baseClasses = 'flex w-full items-center px-4 py-2 text-left text-sm transition-colors'
const activeClasses = isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
return `${baseClasses} ${activeClasses}`
}
// Theme management
const applyTheme = (theme: 'light' | 'dark') => {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
const getSystemTheme = (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = (theme: 'light' | 'dark' | 'system') => {
currentTheme.value = theme
localStorage.setItem('theme', theme)
if (theme === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(theme)
}
dropdownOpen.value = false
}
const toggleTheme = () => {
if (currentTheme.value === 'light') {
setTheme('dark')
} else {
setTheme('light')
}
}
const handleBlur = (event: FocusEvent) => {
// Close dropdown if focus moves outside
const relatedTarget = event.relatedTarget as Element
if (!relatedTarget || !relatedTarget.closest('[role="menu"]')) {
dropdownOpen.value = false
}
}
// Initialize theme
onMounted(() => {
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
if (savedTheme) {
currentTheme.value = savedTheme
} else {
currentTheme.value = 'system'
}
if (currentTheme.value === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(currentTheme.value)
}
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemThemeChange = () => {
if (currentTheme.value === 'system') {
applyTheme(getSystemTheme())
}
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
// Cleanup
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
})
// Watch for theme changes
watch(currentTheme, (newTheme) => {
if (newTheme === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(newTheme)
}
})
</script>