mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 13:11:08 -05:00
- 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
364 lines
11 KiB
Vue
364 lines
11 KiB
Vue
<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>
|