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>
<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__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--line" />
<div class="skeleton skeleton--line skeleton--short" />
@@ -14,46 +24,53 @@
</div>
<!-- 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>
<dl class="detail__fields">
<div class="detail__field">
<dt class="detail__label">Date &amp; Time</dt>
<dd class="detail__value">{{ formattedDateTime }}</dd>
<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.description" class="detail__field">
<dt class="detail__label">Description</dt>
<dd class="detail__value">{{ event.description }}</dd>
<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 v-if="event.location" class="detail__field">
<dt class="detail__label">Location</dt>
<dd class="detail__value">{{ event.location }}</dd>
</div>
<div class="detail__field">
<dt class="detail__label">Attendees</dt>
<dd class="detail__value">{{ event.attendeeCount }}</dd>
<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>
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
This event has ended.
<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__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>
</div>
<!-- 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>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
</div>
</div>
<!-- RSVP bar (only for loaded, non-expired events) -->
<RsvpBar
@@ -210,14 +227,53 @@ onMounted(fetchEvent)
.detail {
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
padding-top: var(--spacing-lg);
/* 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: 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 {
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 {
@@ -233,85 +289,130 @@ onMounted(fetchEvent)
color: var(--color-text-on-gradient);
}
.detail__card {
background: var(--color-card);
border-radius: var(--radius-card);
padding: var(--spacing-xl);
box-shadow: var(--shadow-card);
.detail__body {
flex: 1;
padding: var(--spacing-lg) var(--content-padding);
padding-bottom: 6rem;
}
.detail__content {
display: flex;
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;
text-align: center;
padding-top: 4rem;
}
/* Title */
.detail__title {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-text);
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
word-break: break-word;
line-height: 1.2;
letter-spacing: -0.02em;
}
.detail__fields {
/* Meta rows: icon + text */
.detail__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.detail__field {
.detail__meta-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: center;
gap: var(--spacing-sm);
}
.detail__label {
font-size: 0.8rem;
font-weight: 700;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
.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__value {
font-size: 0.95rem;
color: var(--color-text);
.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: var(--radius-card);
border-radius: 10px;
font-weight: 600;
font-size: 0.9rem;
font-size: 0.85rem;
text-align: center;
}
.detail__banner--expired {
background: #fff3e0;
color: #e65100;
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: 1rem;
font-size: 1.1rem;
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 {
height: 1.6rem;
width: 60%;
height: 2rem;
width: 70%;
border-radius: 8px;
}
.skeleton--line {
height: 1rem;
width: 80%;
width: 85%;
border-radius: 6px;
}
.skeleton--short {
width: 40%;
width: 45%;
}
</style>

View File

@@ -105,7 +105,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken()
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('2026')
wrapper.unmount()