Files
fete/frontend/src/views/EventDetailView.vue
nitrix a9743025a7 Fix hero image transition on event detail page
Replace hard-edged color overlay with CSS mask-image fade-out and
increase hero height to 420px for a seamless blend into the aurora
mesh background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:13:57 +01:00

449 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<main class="detail">
<!-- Hero image with overlaid header -->
<div class="detail__hero">
<img
class="detail__hero-img"
src="@/assets/images/event-hero-placeholder.jpg"
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div>
<div class="detail__body">
<!-- Loading state -->
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
<div class="skeleton skeleton--title" />
<div class="skeleton skeleton--line" />
<div class="skeleton skeleton--line skeleton--short" />
<div class="skeleton skeleton--line" />
</div>
<!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__content">
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
This event has ended.
</div>
<h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__meta">
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Date and time">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</dt>
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
</div>
<div v-if="event.location" class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Location">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.location }}</dd>
</div>
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Attendees">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
</div>
</dl>
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
<div v-if="event.description" class="detail__section">
<h2 class="detail__section-title">About</h2>
<p class="detail__description">{{ event.description }}</p>
</div>
</div>
<!-- Not found state -->
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
<p class="detail__message">Event not found.</p>
</div>
<!-- Error state -->
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
<p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
</div>
</div>
<!-- RSVP bar (only for loaded, non-expired events) -->
<RsvpBar
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
:has-rsvp="!!rsvpName"
@open="sheetOpen = true"
/>
<!-- RSVP bottom sheet -->
<BottomSheet :open="sheetOpen" label="RSVP" @close="sheetOpen = false">
<h2 class="sheet-title">RSVP</h2>
<form class="rsvp-form" @submit.prevent="submitRsvp" novalidate>
<div class="form-group">
<label class="rsvp-form__label" for="rsvp-name">Your name</label>
<input
id="rsvp-name"
v-model.trim="nameInput"
class="form-field"
type="text"
placeholder="e.g. Max Mustermann"
maxlength="100"
required
@input="nameError = ''"
/>
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
</div>
<button class="btn-primary" type="submit" :disabled="submitting">
{{ submitting ? 'Sending…' : "Count me in" }}
</button>
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
</form>
</BottomSheet>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue'
import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema'
type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null)
// RSVP state
const sheetOpen = ref(false)
const nameInput = ref('')
const nameError = ref('')
const submitError = ref('')
const submitting = ref(false)
const rsvpName = ref<string | undefined>(undefined)
const isOrganizer = ref(false)
const attendeeNames = ref<string[] | null>(null)
const formattedDateTime = computed(() => {
if (!event.value) return ''
const formatted = new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeStyle: 'short',
}).format(new Date(event.value.dateTime))
return `${formatted} (${event.value.timezone})`
})
async function fetchEvent() {
state.value = 'loading'
event.value = null
try {
const { data, error, response } = await api.GET('/events/{token}', {
params: { path: { token: route.params.eventToken as string } },
})
if (error) {
state.value = response.status === 404 ? 'not-found' : 'error'
return
}
event.value = data!
state.value = 'loaded'
// Check if current user is the organizer
const orgToken = getOrganizerToken(event.value.eventToken)
isOrganizer.value = !!orgToken
// Fetch attendee list for organizer
if (orgToken) {
fetchAttendees(event.value.eventToken, orgToken)
}
// Restore RSVP status from localStorage
const stored = getRsvp(event.value.eventToken)
if (stored) {
rsvpName.value = stored.rsvpName
}
} catch {
state.value = 'error'
}
}
async function submitRsvp() {
nameError.value = ''
submitError.value = ''
if (!nameInput.value) {
nameError.value = 'Please enter your name.'
return
}
if (nameInput.value.length > 100) {
nameError.value = 'Name must be 100 characters or fewer.'
return
}
submitting.value = true
try {
const { data, error } = await api.POST('/events/{token}/rsvps', {
params: { path: { token: route.params.eventToken as string } },
body: { name: nameInput.value },
})
if (error) {
submitError.value = 'Could not submit RSVP. Please try again.'
return
}
// Persist RSVP in localStorage
saveRsvp(
event.value!.eventToken,
data!.rsvpToken,
data!.name,
event.value!.title,
event.value!.dateTime,
)
// Update UI
rsvpName.value = data!.name
event.value!.attendeeCount += 1
sheetOpen.value = false
nameInput.value = ''
} catch {
submitError.value = 'Could not submit RSVP. Please try again.'
} finally {
submitting.value = false
}
}
async function fetchAttendees(eventToken: string, organizerToken: string) {
try {
const { data, error } = await api.GET('/events/{token}/attendees', {
params: {
path: { token: eventToken },
query: { organizerToken },
},
})
if (!error) {
attendeeNames.value = data!.attendees.map((a) => a.name)
}
} catch {
// Silently degrade — don't show attendee list
}
}
onMounted(fetchEvent)
</script>
<style scoped>
.detail {
display: flex;
flex-direction: column;
/* Break out of .app-container constraints */
width: 100dvw;
flex: 1;
position: relative;
left: 50%;
transform: translateX(-50%);
margin: calc(-1 * var(--content-padding)) 0;
overflow-x: hidden;
}
/* Hero image section */
.detail__hero {
position: relative;
width: 100%;
height: 420px;
overflow: visible;
flex-shrink: 0;
}
.detail__hero-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
}
.detail__hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(27, 23, 48, 0.4) 0%,
transparent 50%
);
}
.detail__header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--content-padding);
padding-top: env(safe-area-inset-top, var(--spacing-lg));
z-index: 1;
}
.detail__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
}
.detail__brand {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-on-gradient);
}
.detail__body {
flex: 1;
padding: var(--spacing-lg) var(--content-padding);
padding-bottom: 6rem;
}
.detail__content {
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
max-width: var(--content-max-width);
margin: 0 auto;
}
.detail__content--center {
align-items: center;
text-align: center;
padding-top: 4rem;
}
/* Title */
.detail__title {
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
word-break: break-word;
line-height: 1.2;
letter-spacing: -0.02em;
}
/* Meta rows: icon + text */
.detail__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.detail__meta-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.detail__meta-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
color: var(--color-text-on-gradient);
}
.detail__meta-text {
font-size: 0.9rem;
color: var(--color-text-on-gradient);
word-break: break-word;
}
/* About section */
.detail__section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.detail__section-title {
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.detail__description {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.85);
line-height: 1.6;
word-break: break-word;
}
/* Expired banner */
.detail__banner {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: 10px;
font-weight: 600;
font-size: 0.85rem;
text-align: center;
}
.detail__banner--expired {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
}
/* Error / not-found message */
.detail__message {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-on-gradient);
}
/* Skeleton shimmer on gradient */
.skeleton {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
}
.skeleton--title {
height: 2rem;
width: 70%;
border-radius: 8px;
}
.skeleton--line {
height: 1rem;
width: 85%;
border-radius: 6px;
}
.skeleton--short {
width: 45%;
}
</style>