Redesign event detail view: full-screen layout with hero image
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m5s
CI / build-and-publish (push) Has been skipped

Replace card-based event detail view with full-screen gradient layout.
Add hero image with gradient overlay, icon-based meta rows, and
"About" section. Content renders directly on the gradient background
with white text for an app-native feel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:47:29 +01:00
parent fe291e36e4
commit 8f78c6cd45
3 changed files with 186 additions and 85 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,12 +1,22 @@
<template> <template>
<main class="detail"> <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"> <header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink> <RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span> <span class="detail__brand">fete</span>
</header> </header>
</div>
<div class="detail__body">
<!-- Loading state --> <!-- Loading state -->
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details"> <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--title" />
<div class="skeleton skeleton--line" /> <div class="skeleton skeleton--line" />
<div class="skeleton skeleton--line skeleton--short" /> <div class="skeleton skeleton--line skeleton--short" />
@@ -14,46 +24,53 @@
</div> </div>
<!-- Loaded state --> <!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__card"> <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> <h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__fields"> <dl class="detail__meta">
<div class="detail__field"> <div class="detail__meta-item">
<dt class="detail__label">Date &amp; Time</dt> <dt class="detail__meta-icon" aria-label="Date and time">
<dd class="detail__value">{{ formattedDateTime }}</dd> <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>
<div v-if="event.description" class="detail__field"> <div v-if="event.location" class="detail__meta-item">
<dt class="detail__label">Description</dt> <dt class="detail__meta-icon" aria-label="Location">
<dd class="detail__value">{{ event.description }}</dd> <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>
<div v-if="event.location" class="detail__field"> <div class="detail__meta-item">
<dt class="detail__label">Location</dt> <dt class="detail__meta-icon" aria-label="Attendees">
<dd class="detail__value">{{ event.location }}</dd> <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>
</div> </dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
<div class="detail__field">
<dt class="detail__label">Attendees</dt>
<dd class="detail__value">{{ event.attendeeCount }}</dd>
</div> </div>
</dl> </dl>
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status"> <div v-if="event.description" class="detail__section">
This event has ended. <h2 class="detail__section-title">About</h2>
<p class="detail__description">{{ event.description }}</p>
</div> </div>
</div> </div>
<!-- Not found state --> <!-- Not found state -->
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status"> <div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
<p class="detail__message">Event not found.</p> <p class="detail__message">Event not found.</p>
</div> </div>
<!-- Error state --> <!-- Error state -->
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert"> <div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
<p class="detail__message">Something went wrong.</p> <p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button> <button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
</div> </div>
</div>
<!-- RSVP bar (only for loaded, non-expired events) --> <!-- RSVP bar (only for loaded, non-expired events) -->
<RsvpBar <RsvpBar
@@ -210,14 +227,53 @@ onMounted(fetchEvent)
.detail { .detail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-2xl); /* Break out of .app-container constraints */
padding-top: var(--spacing-lg); 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: 260px;
overflow: hidden;
flex-shrink: 0;
}
.detail__hero-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail__hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.4) 0%,
transparent 50%,
var(--color-gradient-start) 100%
);
} }
.detail__header { .detail__header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); 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 { .detail__back {
@@ -233,85 +289,130 @@ onMounted(fetchEvent)
color: var(--color-text-on-gradient); color: var(--color-text-on-gradient);
} }
.detail__card { .detail__body {
background: var(--color-card); flex: 1;
border-radius: var(--radius-card); padding: var(--spacing-lg) var(--content-padding);
padding: var(--spacing-xl); padding-bottom: 6rem;
box-shadow: var(--shadow-card); }
.detail__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-lg); gap: var(--spacing-2xl);
max-width: var(--content-max-width);
margin: 0 auto;
} }
.detail__card--center { .detail__content--center {
align-items: center; align-items: center;
text-align: center; text-align: center;
padding-top: 4rem;
} }
/* Title */
.detail__title { .detail__title {
font-size: 1.4rem; font-size: 2rem;
font-weight: 700; font-weight: 800;
color: var(--color-text); color: var(--color-text-on-gradient);
word-break: break-word; word-break: break-word;
line-height: 1.2;
letter-spacing: -0.02em;
} }
.detail__fields { /* Meta rows: icon + text */
.detail__meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-md); gap: var(--spacing-md);
} }
.detail__field { .detail__meta-item {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.15rem; gap: var(--spacing-sm);
} }
.detail__label { .detail__meta-icon {
font-size: 0.8rem; flex-shrink: 0;
font-weight: 700; width: 36px;
color: #888; height: 36px;
text-transform: uppercase; display: flex;
letter-spacing: 0.04em; align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
color: var(--color-text-on-gradient);
} }
.detail__value { .detail__meta-text {
font-size: 0.95rem; font-size: 0.9rem;
color: var(--color-text); color: var(--color-text-on-gradient);
word-break: break-word; 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 { .detail__banner {
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-card); border-radius: 10px;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.85rem;
text-align: center; text-align: center;
} }
.detail__banner--expired { .detail__banner--expired {
background: #fff3e0; background: rgba(255, 255, 255, 0.12);
color: #e65100; color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
} }
/* Error / not-found message */
.detail__message { .detail__message {
font-size: 1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--color-text); 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 sizes */
.skeleton--title { .skeleton--title {
height: 1.6rem; height: 2rem;
width: 60%; width: 70%;
border-radius: 8px;
} }
.skeleton--line { .skeleton--line {
height: 1rem; height: 1rem;
width: 80%; width: 85%;
border-radius: 6px;
} }
.skeleton--short { .skeleton--short {
width: 40%; width: 45%;
} }
</style> </style>

View File

@@ -105,7 +105,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
const dateField = wrapper.findAll('.detail__value')[0]! const dateField = wrapper.findAll('.detail__meta-text')[0]!
expect(dateField.text()).toContain('(Europe/Berlin)') expect(dateField.text()).toContain('(Europe/Berlin)')
expect(dateField.text()).toContain('2026') expect(dateField.text()).toContain('2026')
wrapper.unmount() wrapper.unmount()