Add iCal download for calendar integration #43

Merged
nitrix merged 7 commits from 019-ical-download into master 2026-03-14 11:40:43 +01:00
3 changed files with 140 additions and 74 deletions
Showing only changes of commit 9483e9b1f7 - Show all commits

View File

@@ -317,6 +317,72 @@ input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrap
to { --glow-angle: 360deg; } to { --glow-angle: 360deg; }
} }
/* ── Fixed Bottom Bar Components ── */
/* CTA wrapper (text button, e.g. "I'm attending!", "Post an update") */
.bar-cta {
flex: 1;
min-width: 0;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.bar-cta:hover {
transform: scale(1.02);
}
.bar-cta:active {
transform: scale(0.98);
}
.bar-cta-btn {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
border: none;
cursor: pointer;
}
/* Icon wrapper (e.g. calendar, bookmark buttons) */
.bar-icon {
flex-shrink: 0;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.bar-icon:hover {
transform: scale(1.02);
}
.bar-icon:active {
transform: scale(0.98);
}
.bar-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-md);
border-radius: calc(var(--radius-button) - 2px);
border: none;
cursor: pointer;
color: var(--color-text-on-gradient);
line-height: 0;
}
.bar-icon-btn svg {
display: block;
}
/* Utility */ /* Utility */
.text-center { .text-center {
text-align: center; text-align: center;

View File

@@ -3,20 +3,30 @@
<div class="rsvp-bar__inner"> <div class="rsvp-bar__inner">
<!-- Status state: already RSVPed --> <!-- Status state: already RSVPed -->
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper"> <div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
<div <div class="rsvp-bar__status-row">
class="rsvp-bar__status" <div
role="button" class="rsvp-bar__status"
tabindex="0" role="button"
:aria-expanded="expanded" tabindex="0"
aria-label="You're attending. Tap to show cancel option." :aria-expanded="expanded"
@click="expanded = !expanded" aria-label="You're attending. Tap to show cancel option."
@keydown.enter.prevent="expanded = !expanded" @click="expanded = !expanded"
@keydown.space.prevent="expanded = !expanded" @keydown.enter.prevent="expanded = !expanded"
@keydown.escape="expanded = false" @keydown.space.prevent="expanded = !expanded"
> @keydown.escape="expanded = false"
<span class="rsvp-bar__check" aria-hidden="true"></span> >
<span class="rsvp-bar__text">You're attending!</span> <span class="rsvp-bar__check" aria-hidden="true"></span>
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true"></span> <span class="rsvp-bar__text">You're attending!</span>
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true"></span>
</div>
<button
class="rsvp-bar__calendar-glass"
type="button"
aria-label="Add to calendar"
@click="$emit('calendar')"
>
<svg width="20" height="20" 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>
</button>
</div> </div>
<Transition name="rsvp-bar-cancel"> <Transition name="rsvp-bar-cancel">
<button <button
@@ -32,14 +42,9 @@
<!-- CTA state: no RSVP yet --> <!-- CTA state: no RSVP yet -->
<div v-else class="rsvp-bar__row"> <div v-else class="rsvp-bar__row">
<div class="rsvp-bar__cta glow-border glow-border--animated"> <div class="bar-icon glow-border glow-border--animated">
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
I'm attending!
</button>
</div>
<div class="rsvp-bar__bookmark glow-border glow-border--animated">
<button <button
class="rsvp-bar__bookmark-inner glass-inner" class="bar-icon-btn glass-inner"
type="button" type="button"
:aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'" :aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'"
@click="$emit('bookmark')" @click="$emit('bookmark')"
@@ -47,6 +52,21 @@
<svg width="20" height="20" viewBox="0 0 24 24" :fill="bookmarked ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" :fill="bookmarked ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
</button> </button>
</div> </div>
<div class="bar-cta glow-border glow-border--animated">
<button class="bar-cta-btn glass-inner" type="button" @click="$emit('open')">
I'm attending!
</button>
</div>
<div class="bar-icon glow-border glow-border--animated">
<button
class="bar-icon-btn glass-inner"
type="button"
aria-label="Add to calendar"
@click="$emit('calendar')"
>
<svg width="20" height="20" 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>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -64,6 +84,7 @@ defineEmits<{
open: [] open: []
cancel: [] cancel: []
bookmark: [] bookmark: []
calendar: []
}>() }>()
const expanded = ref(false) const expanded = ref(false)
@@ -111,34 +132,6 @@ watch(expanded, (isExpanded) => {
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.rsvp-bar__cta {
flex: 1;
min-width: 0;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-bar__cta:hover {
transform: scale(1.02);
}
.rsvp-bar__cta:active {
transform: scale(0.98);
}
.rsvp-bar__cta-inner {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
border: none;
cursor: pointer;
}
.rsvp-bar__status-wrapper { .rsvp-bar__status-wrapper {
display: flex; display: flex;
@@ -146,7 +139,14 @@ watch(expanded, (isExpanded) => {
gap: var(--spacing-xs); gap: var(--spacing-xs);
} }
.rsvp-bar__status-row {
display: flex;
gap: var(--spacing-sm);
}
.rsvp-bar__status { .rsvp-bar__status {
flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -225,35 +225,35 @@ watch(expanded, (isExpanded) => {
transform: translateY(-4px); transform: translateY(-4px);
} }
.rsvp-bar__bookmark {
/* Calendar button — glassmorphic variant (post-RSVP status row) */
.rsvp-bar__calendar-glass {
flex-shrink: 0; flex-shrink: 0;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-bar__bookmark:hover {
transform: scale(1.02);
}
.rsvp-bar__bookmark:active {
transform: scale(0.98);
}
.rsvp-bar__bookmark-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-md); padding: var(--spacing-md);
border-radius: calc(var(--radius-button) - 2px); background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: none; border: 1px solid var(--color-glass-border);
cursor: pointer; backdrop-filter: blur(16px);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
color: var(--color-text-on-gradient); color: var(--color-text-on-gradient);
cursor: pointer;
line-height: 0; line-height: 0;
transition: transform 0.1s ease, background 0.15s ease;
} }
.rsvp-bar__bookmark-inner svg { .rsvp-bar__calendar-glass:hover {
transform: scale(1.02);
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
}
.rsvp-bar__calendar-glass:active {
transform: scale(0.98);
}
.rsvp-bar__calendar-glass svg {
display: block; display: block;
} }

View File

@@ -5,8 +5,8 @@ import RsvpBar from '../RsvpBar.vue'
describe('RsvpBar', () => { describe('RsvpBar', () => {
it('renders CTA button when hasRsvp is false', () => { it('renders CTA button when hasRsvp is false', () => {
const wrapper = mount(RsvpBar) const wrapper = mount(RsvpBar)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true) expect(wrapper.find('.bar-cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending!") expect(wrapper.find('.bar-cta-btn').text()).toBe("I'm attending!")
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false) expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
}) })
@@ -14,17 +14,17 @@ describe('RsvpBar', () => {
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true) expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!") expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false) expect(wrapper.find('.bar-cta').exists()).toBe(false)
}) })
it('emits open when CTA button is clicked', async () => { it('emits open when CTA button is clicked', async () => {
const wrapper = mount(RsvpBar) const wrapper = mount(RsvpBar)
await wrapper.find('.rsvp-bar__cta-inner').trigger('click') await wrapper.find('.bar-cta-btn').trigger('click')
expect(wrapper.emitted('open')).toHaveLength(1) expect(wrapper.emitted('open')).toHaveLength(1)
}) })
it('does not render CTA button when hasRsvp is true', () => { it('does not render CTA button when hasRsvp is true', () => {
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
expect(wrapper.find('button').exists()).toBe(false) expect(wrapper.find('.bar-cta-btn').exists()).toBe(false)
}) })
}) })