fix: resolve sidebar nav hydration mismatch and admin events 500 error
Wrap auth-dependent sidebar navigation and meta in ClientOnly with SSR fallback slots to prevent hydration mismatch that caused all authenticated nav links to point to wrong pages. Fix admin events page crash by replacing empty string USelect values with 'all'.
This commit is contained in:
parent
27d8f678ad
commit
f16f9ada64
3 changed files with 181 additions and 80 deletions
|
|
@ -7,85 +7,123 @@
|
||||||
|
|
||||||
<!-- Navigation sections -->
|
<!-- Navigation sections -->
|
||||||
<div class="sidebar-body">
|
<div class="sidebar-body">
|
||||||
<template v-if="isAuthenticated">
|
<ClientOnly>
|
||||||
<!-- Logged-in nav -->
|
<template v-if="isAuthenticated">
|
||||||
<div class="sidebar-section">You</div>
|
<!-- Logged-in nav -->
|
||||||
<ul class="sidebar-nav">
|
<div class="sidebar-section">You</div>
|
||||||
<li v-for="item in youItems" :key="item.path">
|
<ul class="sidebar-nav">
|
||||||
<NuxtLink
|
<li v-for="item in youItems" :key="item.path">
|
||||||
:to="item.path"
|
<NuxtLink
|
||||||
:class="{ active: isActive(item.path) }"
|
:to="item.path"
|
||||||
@click="handleNavigate"
|
:class="{ active: isActive(item.path) }"
|
||||||
>{{ item.label }}</NuxtLink>
|
@click="handleNavigate"
|
||||||
</li>
|
>{{ item.label }}</NuxtLink>
|
||||||
</ul>
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Explore</div>
|
<div class="sidebar-section">Explore</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in exploreItems" :key="item.path">
|
<li v-for="item in exploreItems" :key="item.path">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Community</div>
|
<div class="sidebar-section">Community</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in communityItems" :key="item.path">
|
<li v-for="item in communityItems" :key="item.path">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Public nav -->
|
<!-- Public nav -->
|
||||||
<div class="sidebar-section">Navigate</div>
|
<div class="sidebar-section">Navigate</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in publicItems" :key="item.path">
|
<li v-for="item in publicItems" :key="item.path">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Join</div>
|
<div class="sidebar-section">Join</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in joinItems" :key="item.path">
|
<li v-for="item in joinItems" :key="item.path">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<!-- Public nav (SSR fallback) -->
|
||||||
|
<div class="sidebar-section">Navigate</div>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li v-for="item in publicItems" :key="item.path">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.path"
|
||||||
|
:class="{ active: isActive(item.path) }"
|
||||||
|
@click="handleNavigate"
|
||||||
|
>{{ item.label }}</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-section">Join</div>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li v-for="item in joinItems" :key="item.path">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.path"
|
||||||
|
:class="{ active: isActive(item.path) }"
|
||||||
|
@click="handleNavigate"
|
||||||
|
>{{ item.label }}</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Meta at bottom -->
|
<!-- Meta at bottom -->
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<template v-if="isAuthenticated">
|
<ClientOnly>
|
||||||
<span class="member-name">{{ memberData?.name || 'Member' }}</span><br>
|
<template v-if="isAuthenticated">
|
||||||
<span
|
<span class="member-name">{{ memberData?.name || 'Member' }}</span><br>
|
||||||
v-if="memberData?.circle"
|
<span
|
||||||
class="member-circle"
|
v-if="memberData?.circle"
|
||||||
:style="{ color: `var(--c-${memberData.circle})` }"
|
class="member-circle"
|
||||||
>{{ memberData.circle }}</span>
|
:style="{ color: `var(--c-${memberData.circle})` }"
|
||||||
<br v-if="memberData?.circle">
|
>{{ memberData.circle }}</span>
|
||||||
<a href="#" @click.prevent="handleLogout">Sign out</a>
|
<br v-if="memberData?.circle">
|
||||||
</template>
|
<a href="#" @click.prevent="handleLogout">Sign out</a>
|
||||||
<template v-else>
|
</template>
|
||||||
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
<template v-else>
|
||||||
A Canadian nonprofit<br>
|
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
||||||
<a href="#" @click.prevent="openLogin">Sign in</a>
|
A Canadian nonprofit<br>
|
||||||
</template>
|
<a href="#" @click.prevent="openLogin">Sign in</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
||||||
|
A Canadian nonprofit<br>
|
||||||
|
<a href="#" @click.prevent="openLogin">Sign in</a>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
<ClientOnly>
|
||||||
|
<ColorModeToggle />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
63
app/components/ColorModeToggle.vue
Normal file
63
app/components/ColorModeToggle.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<div class="color-mode-toggle">
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:class="{ active: colorMode.preference === option.value }"
|
||||||
|
@click="colorMode.preference = option.value"
|
||||||
|
>{{ option.label }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: 'Light', value: 'light' },
|
||||||
|
{ label: 'System', value: 'system' },
|
||||||
|
{ label: 'Dark', value: 'dark' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-mode-toggle button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-mode-toggle button + button {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-mode-toggle button:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-mode-toggle button.active {
|
||||||
|
color: var(--candle);
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When active button is adjacent to dashed, restore left border */
|
||||||
|
.color-mode-toggle button.active + button {
|
||||||
|
border-left: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.color-mode-toggle button:has(+ button.active) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<USelect
|
<USelect
|
||||||
v-model="typeFilter"
|
v-model="typeFilter"
|
||||||
:items="[
|
:items="[
|
||||||
{ label: 'All Types', value: '' },
|
{ label: 'All Types', value: 'all' },
|
||||||
{ label: 'Community', value: 'community' },
|
{ label: 'Community', value: 'community' },
|
||||||
{ label: 'Workshop', value: 'workshop' },
|
{ label: 'Workshop', value: 'workshop' },
|
||||||
{ label: 'Social', value: 'social' },
|
{ label: 'Social', value: 'social' },
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
<USelect
|
<USelect
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
:items="[
|
:items="[
|
||||||
{ label: 'All Status', value: '' },
|
{ label: 'All Status', value: 'all' },
|
||||||
{ label: 'Upcoming', value: 'upcoming' },
|
{ label: 'Upcoming', value: 'upcoming' },
|
||||||
{ label: 'Ongoing', value: 'ongoing' },
|
{ label: 'Ongoing', value: 'ongoing' },
|
||||||
{ label: 'Past', value: 'past' },
|
{ label: 'Past', value: 'past' },
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
<USelect
|
<USelect
|
||||||
v-model="seriesFilter"
|
v-model="seriesFilter"
|
||||||
:items="[
|
:items="[
|
||||||
{ label: 'All Events', value: '' },
|
{ label: 'All Events', value: 'all' },
|
||||||
{ label: 'Series Events Only', value: 'series-only' },
|
{ label: 'Series Events Only', value: 'series-only' },
|
||||||
{ label: 'Standalone Only', value: 'standalone-only' },
|
{ label: 'Standalone Only', value: 'standalone-only' },
|
||||||
]"
|
]"
|
||||||
|
|
@ -370,9 +370,9 @@ const {
|
||||||
} = await useFetch("/api/admin/events");
|
} = await useFetch("/api/admin/events");
|
||||||
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const typeFilter = ref("");
|
const typeFilter = ref("all");
|
||||||
const statusFilter = ref("");
|
const statusFilter = ref("all");
|
||||||
const seriesFilter = ref("");
|
const seriesFilter = ref("all");
|
||||||
|
|
||||||
const filteredEvents = computed(() => {
|
const filteredEvents = computed(() => {
|
||||||
if (!events.value) return [];
|
if (!events.value) return [];
|
||||||
|
|
@ -384,14 +384,14 @@ const filteredEvents = computed(() => {
|
||||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
|
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||||
|
|
||||||
const matchesType =
|
const matchesType =
|
||||||
!typeFilter.value || event.eventType === typeFilter.value;
|
typeFilter.value === "all" || event.eventType === typeFilter.value;
|
||||||
|
|
||||||
const eventStatus = getEventStatus(event);
|
const eventStatus = getEventStatus(event);
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
!statusFilter.value || eventStatus.toLowerCase() === statusFilter.value;
|
statusFilter.value === "all" || eventStatus.toLowerCase() === statusFilter.value;
|
||||||
|
|
||||||
const matchesSeries =
|
const matchesSeries =
|
||||||
!seriesFilter.value ||
|
seriesFilter.value === "all" ||
|
||||||
(seriesFilter.value === "series-only" && event.series?.isSeriesEvent) ||
|
(seriesFilter.value === "series-only" && event.series?.isSeriesEvent) ||
|
||||||
(seriesFilter.value === "standalone-only" &&
|
(seriesFilter.value === "standalone-only" &&
|
||||||
!event.series?.isSeriesEvent);
|
!event.series?.isSeriesEvent);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue