Add client-side watch/bookmark functionality: users can save events to localStorage without RSVPing via a bookmark button next to the "I'm attending" CTA. Watched events appear in the event list with a "Watching" label. Bookmark is only visible for visitors (not attendees or organizers). Includes spec, plan, research, tasks, unit tests, and E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
187 lines
4.1 KiB
Vue
187 lines
4.1 KiB
Vue
<template>
|
||
<div
|
||
class="event-card glass"
|
||
: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">{{ displayTime }}</span>
|
||
</RouterLink>
|
||
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
|
||
{{ eventRole === 'organizer' ? 'Organizer' : eventRole === 'attendee' ? 'Attendee' : 'Watching' }}
|
||
</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' | 'watcher'
|
||
timeDisplayMode?: 'clock' | 'relative'
|
||
dateTime?: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
delete: [eventToken: string]
|
||
}>()
|
||
|
||
const displayTime = computed(() => {
|
||
if (props.timeDisplayMode === 'clock' && props.dateTime) {
|
||
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime))
|
||
}
|
||
return props.relativeTime
|
||
})
|
||
|
||
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;
|
||
border-radius: var(--radius-card);
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
gap: var(--spacing-sm);
|
||
transition: background 0.2s ease, border-color 0.2s ease;
|
||
}
|
||
|
||
.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-on-gradient);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.event-card__time {
|
||
font-size: 0.8rem;
|
||
font-weight: 400;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.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: var(--color-text-on-gradient);
|
||
}
|
||
|
||
.event-card__badge--attendee {
|
||
background: var(--color-glass-strong);
|
||
color: var(--color-text-bright);
|
||
}
|
||
|
||
.event-card__badge--watcher {
|
||
background: var(--color-glass);
|
||
color: var(--color-text-secondary);
|
||
border: 1px solid var(--color-glass-border);
|
||
}
|
||
|
||
.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: var(--color-text-muted);
|
||
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>
|