Redesign event detail view: full-screen layout with hero image
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:
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -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">←</RouterLink>
|
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</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 & 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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user