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>
173 lines
3.5 KiB
Vue
173 lines
3.5 KiB
Vue
<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>
|