Add event list feature (009-list-events)
Enable users to see all their saved events on the home screen, sorted by date with upcoming events first. Key capabilities: - EventCard with title, relative time display, and organizer/attendee role badge - Sortable EventList with past-event visual distinction (faded style) - Empty state when no events are stored - Swipe-to-delete gesture with confirmation dialog - Floating action button for quick event creation - Rename router param :token → :eventToken across all views - useRelativeTime composable (Intl.RelativeTimeFormat) - useEventStorage: add validation, removeEvent(), reactive versioning - Full E2E and unit test coverage for all new components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
172
frontend/src/components/EventCard.vue
Normal file
172
frontend/src/components/EventCard.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div
|
||||
class="event-card"
|
||||
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
|
||||
:style="swipeStyle"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
>
|
||||
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
|
||||
<span class="event-card__title">{{ title }}</span>
|
||||
<span class="event-card__time">{{ relativeTime }}</span>
|
||||
</RouterLink>
|
||||
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
|
||||
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
|
||||
</span>
|
||||
<button
|
||||
class="event-card__delete"
|
||||
type="button"
|
||||
:aria-label="`Remove ${title}`"
|
||||
@click.stop="$emit('delete', eventToken)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
eventToken: string
|
||||
title: string
|
||||
relativeTime: string
|
||||
isPast: boolean
|
||||
eventRole?: 'organizer' | 'attendee'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [eventToken: string]
|
||||
}>()
|
||||
|
||||
const SWIPE_THRESHOLD = 80
|
||||
|
||||
const startX = ref(0)
|
||||
const deltaX = ref(0)
|
||||
const isSwiping = ref(false)
|
||||
|
||||
const swipeStyle = computed(() => {
|
||||
if (deltaX.value === 0) return {}
|
||||
return { transform: `translateX(${deltaX.value}px)` }
|
||||
})
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
startX.value = touch.clientX
|
||||
deltaX.value = 0
|
||||
isSwiping.value = false
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
const diff = touch.clientX - startX.value
|
||||
// Only allow leftward swipe
|
||||
if (diff < 0) {
|
||||
deltaX.value = diff
|
||||
isSwiping.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (deltaX.value < -SWIPE_THRESHOLD) {
|
||||
emit('delete', props.eventToken)
|
||||
}
|
||||
deltaX.value = 0
|
||||
isSwiping.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.event-card--past {
|
||||
opacity: 0.6;
|
||||
filter: saturate(0.5);
|
||||
}
|
||||
|
||||
.event-card:not(.event-card--swiping) {
|
||||
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.event-card__link {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-card__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-card__time {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.event-card__badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-card__badge--organizer {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.event-card__badge--attendee {
|
||||
background: #e0e0e0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.event-card__delete {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.event-card__delete:hover {
|
||||
color: #d32f2f;
|
||||
background: rgba(211, 47, 47, 0.08);
|
||||
}
|
||||
|
||||
.event-card__delete:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user