You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
377 lines
10 KiB
377 lines
10 KiB
<script setup lang="ts">
|
|
import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'lucide-vue-next'
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import confetti from 'canvas-confetti'
|
|
|
|
type RoleCode = 'private' | 'educator' | 'company'
|
|
|
|
interface ProductArea {
|
|
id: string
|
|
label: string
|
|
icon: any
|
|
enabled: boolean
|
|
visible: boolean
|
|
badge?: string
|
|
route: string
|
|
roleVisibility?: 'all' | RoleCode[]
|
|
}
|
|
|
|
const areas: ProductArea[] = [
|
|
{
|
|
id: 'start',
|
|
label: 'Start',
|
|
icon: Home,
|
|
enabled: true,
|
|
visible: true,
|
|
route: '/',
|
|
roleVisibility: 'all',
|
|
},
|
|
{
|
|
id: 'makerspace',
|
|
label: 'Makerspace',
|
|
icon: Wrench,
|
|
enabled: true,
|
|
visible: true,
|
|
route: '/products',
|
|
roleVisibility: 'all',
|
|
},
|
|
{
|
|
id: 'educator',
|
|
label: 'Bildung',
|
|
icon: GraduationCap,
|
|
enabled: true,
|
|
visible: true,
|
|
badge: 'Demnächst',
|
|
route: '/educator',
|
|
roleVisibility: ['educator'],
|
|
},
|
|
{
|
|
id: 'experimenta',
|
|
label: 'experimenta',
|
|
icon: Sparkles,
|
|
enabled: true,
|
|
visible: true,
|
|
badge: 'Demnächst',
|
|
route: '/experimenta',
|
|
roleVisibility: 'all',
|
|
},
|
|
{
|
|
id: 'pedagogical-offers',
|
|
label: 'Pädagogische Angebote',
|
|
icon: GraduationCap,
|
|
enabled: true,
|
|
visible: true,
|
|
route: '/experimenta',
|
|
roleVisibility: ['educator'],
|
|
},
|
|
{
|
|
id: 'labs',
|
|
label: 'Labore',
|
|
icon: FlaskConical,
|
|
enabled: false,
|
|
visible: true,
|
|
badge: 'Demnächst',
|
|
route: '/labs',
|
|
roleVisibility: ['educator', 'company'],
|
|
},
|
|
]
|
|
|
|
const route = useRoute()
|
|
const { activeRole } = useActiveRole()
|
|
|
|
// Filter areas by role visibility
|
|
const visibleAreas = computed(() => {
|
|
return areas.filter(area => {
|
|
// Legacy visible flag check
|
|
if (!area.visible) return false
|
|
|
|
// No role requirement = visible to all (backward compatible)
|
|
if (!area.roleVisibility) return true
|
|
|
|
// Explicitly set to 'all'
|
|
if (area.roleVisibility === 'all') return true
|
|
|
|
// Check if user's active role matches
|
|
return area.roleVisibility.includes(activeRole.value as RoleCode)
|
|
})
|
|
})
|
|
|
|
const currentArea = computed(() => {
|
|
// Determine current area based on route - check visibleAreas array dynamically
|
|
const currentPath = route.path
|
|
|
|
// Exact match for root path
|
|
if (currentPath === '/') {
|
|
return visibleAreas.value.find(area => area.route === '/')?.id || ''
|
|
}
|
|
|
|
// Find area where route path starts with area.route
|
|
const matchedArea = visibleAreas.value.find(area =>
|
|
area.route !== '/' && currentPath.startsWith(area.route)
|
|
)
|
|
|
|
return matchedArea?.id || ''
|
|
})
|
|
|
|
// Track previous area IDs for animation detection
|
|
const previousAreaIds = ref<string[]>([])
|
|
const newlyAddedAreaIds = ref<string[]>([])
|
|
const tabRefs = ref<Record<string, HTMLElement>>({})
|
|
|
|
// Watch for changes in visible areas to trigger animations
|
|
watch(visibleAreas, (newAreas, oldAreas) => {
|
|
const newAreaIds = newAreas.map(a => a.id)
|
|
const oldAreaIds = oldAreas?.map(a => a.id) || []
|
|
|
|
// Check if current route is still accessible with new visible areas
|
|
const currentPath = route.path
|
|
if (currentPath !== '/') {
|
|
const isCurrentRouteStillVisible = newAreas.some(area =>
|
|
area.route !== '/' && currentPath.startsWith(area.route)
|
|
)
|
|
if (!isCurrentRouteStillVisible) {
|
|
// Delay navigation until fade-out animation completes (300ms + 50ms buffer)
|
|
setTimeout(() => {
|
|
navigateTo('/')
|
|
}, 350)
|
|
}
|
|
}
|
|
|
|
// Find newly added areas
|
|
const addedIds = newAreaIds.filter(id => !oldAreaIds.includes(id))
|
|
|
|
if (addedIds.length > 0) {
|
|
// Mark ALL newly added tabs for highlight animation
|
|
newlyAddedAreaIds.value = addedIds
|
|
|
|
// Trigger confetti for each new tab with staggered delay (wave effect)
|
|
addedIds.forEach((areaId, index) => {
|
|
setTimeout(() => {
|
|
const element = tabRefs.value[areaId]
|
|
if (element) {
|
|
triggerConfetti(element)
|
|
}
|
|
}, 300 + (index * 150)) // Stagger: 300ms, 450ms, 600ms, ...
|
|
})
|
|
|
|
// Clear all highlights after animation completes
|
|
// Animation timeline: glow 2x + pulse 2x + opacity fade = 2.0s
|
|
// Wait 2.1s to ensure all animations complete before removing class
|
|
setTimeout(() => {
|
|
newlyAddedAreaIds.value = []
|
|
}, 2100)
|
|
}
|
|
|
|
previousAreaIds.value = newAreaIds
|
|
}, { deep: true })
|
|
|
|
// Initialize previous area IDs
|
|
onMounted(() => {
|
|
previousAreaIds.value = visibleAreas.value.map(a => a.id)
|
|
})
|
|
|
|
// Confetti effect from element position
|
|
function triggerConfetti(element: HTMLElement) {
|
|
const rect = element.getBoundingClientRect()
|
|
const x = (rect.left + rect.width / 2) / window.innerWidth
|
|
const y = (rect.top + rect.height / 2) / window.innerHeight
|
|
|
|
// Confetti burst from tab position
|
|
confetti({
|
|
particleCount: 50,
|
|
spread: 120,
|
|
origin: { x, y },
|
|
colors: ['#E91E85', '#FF6B9D', '#C77DFF', '#9D4EDD'],
|
|
ticks: 60,
|
|
gravity: 1.3, // Increased gravity for faster fall
|
|
scalar: 0.8,
|
|
startVelocity: 15, // Reduced initial velocity (default: 45)
|
|
})
|
|
}
|
|
|
|
function navigateToArea(area: ProductArea) {
|
|
if (area.enabled) {
|
|
navigateTo(area.route)
|
|
}
|
|
}
|
|
|
|
function setTabRef(areaId: string, el: any) {
|
|
if (el) {
|
|
// Extract the actual DOM element from Vue component instance
|
|
const domElement = el.$el || el
|
|
tabRefs.value[areaId] = domElement
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="w-full">
|
|
<!-- Desktop: Tabs -->
|
|
<Tabs :model-value="currentArea" class="hidden md:block">
|
|
<TabsList class="h-auto p-2 bg-white/5">
|
|
<TransitionGroup name="tab" tag="div" class="flex items-center gap-1">
|
|
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
|
|
:value="area.id" :disabled="!area.enabled" :class="[
|
|
'relative gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md transition-all duration-300',
|
|
!area.enabled && 'opacity-50 cursor-not-allowed',
|
|
newlyAddedAreaIds.includes(area.id) && 'tab-highlight',
|
|
]" @click="navigateToArea(area)">
|
|
<component :is="area.icon" class="h-4 w-4" />
|
|
<span>{{ area.label }}</span>
|
|
<Badge v-if="area.badge" :class="[
|
|
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
|
|
currentArea === area.id
|
|
? 'bg-white/90 text-purple-950 border-white/50'
|
|
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
|
|
]">
|
|
{{ area.badge }}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
</TransitionGroup>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
|
|
<div class="md:hidden overflow-x-auto scrollbar-hide">
|
|
<div class="inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max">
|
|
<TransitionGroup name="tab" tag="div" class="inline-flex items-center gap-2">
|
|
<button v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
|
|
:disabled="!area.enabled" :class="[
|
|
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[25px] px-4 py-3 text-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-0 duration-300',
|
|
currentArea === area.id
|
|
? 'bg-accent text-white shadow-md'
|
|
: 'text-white/70 hover:text-white',
|
|
!area.enabled && 'opacity-50 cursor-not-allowed',
|
|
newlyAddedAreaIds.includes(area.id) && 'tab-highlight',
|
|
]" @click="navigateToArea(area)">
|
|
<component :is="area.icon" class="h-4 w-4" />
|
|
<span>{{ area.label }}</span>
|
|
<Badge v-if="area.badge" :class="[
|
|
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
|
|
currentArea === area.id
|
|
? 'bg-white/90 text-purple-950 border-white/50'
|
|
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
|
|
]">
|
|
{{ area.badge }}
|
|
</Badge>
|
|
</button>
|
|
</TransitionGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Hide scrollbar but keep functionality */
|
|
.scrollbar-hide::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.scrollbar-hide {
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
/* Vue TransitionGroup animations for tabs */
|
|
.tab-enter-active {
|
|
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
|
|
.tab-leave-active {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
position: absolute;
|
|
}
|
|
|
|
.tab-enter-from {
|
|
opacity: 0;
|
|
transform: translateX(-20px) scale(0.9);
|
|
}
|
|
|
|
.tab-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-10px) scale(0.95);
|
|
}
|
|
|
|
.tab-move {
|
|
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
/* Highlight animation for newly added tabs - 2x blink then stay transparent */
|
|
@keyframes highlight-glow {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
|
|
}
|
|
|
|
/* First blink */
|
|
25% {
|
|
box-shadow: 0 0 20px 4px rgba(233, 30, 133, 0.6),
|
|
0 0 40px 8px rgba(201, 125, 255, 0.4);
|
|
}
|
|
|
|
50% {
|
|
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
|
|
}
|
|
|
|
/* Second blink */
|
|
75% {
|
|
box-shadow: 0 0 20px 4px rgba(233, 30, 133, 0.6),
|
|
0 0 40px 8px rgba(201, 125, 255, 0.4);
|
|
}
|
|
|
|
/* Stay transparent after 2nd blink */
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
|
|
}
|
|
}
|
|
|
|
@keyframes highlight-pulse {
|
|
|
|
0%,
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
|
|
50% {
|
|
transform: scale(1.05);
|
|
}
|
|
}
|
|
|
|
/* Opacity fade for background (Chrome-compatible workaround) */
|
|
@keyframes highlight-opacity {
|
|
0% {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Stay visible during first blink */
|
|
25% {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Start fading slowly after first blink */
|
|
50% {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Fade to transparent gradually */
|
|
100% {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.tab-highlight {
|
|
/* Animations */
|
|
animation: highlight-glow 2s ease-in-out 1 forwards,
|
|
highlight-pulse 0.5s ease-in-out 2;
|
|
}
|
|
|
|
.tab-highlight::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: inherit;
|
|
background: linear-gradient(135deg, rgba(233, 30, 133, 0.2), rgba(201, 125, 255, 0.2));
|
|
pointer-events: none;
|
|
animation: highlight-opacity 2s ease-in-out 1 forwards;
|
|
}
|
|
</style>
|
|
|