detail page port
This commit is contained in:
@@ -1,32 +1,165 @@
|
||||
<template>
|
||||
<component :is="tag" class="btn">
|
||||
<svg-btn-outline />
|
||||
<component
|
||||
:class="[`btn p-xs size-${size}`, { secondary, number, hover }]"
|
||||
:is="component"
|
||||
:href="href"
|
||||
:field="linkField"
|
||||
>
|
||||
<span class="text" v-if="linkField">
|
||||
<span>{{ linkField.text }}</span>
|
||||
<span>{{ linkField.text }}</span>
|
||||
</span>
|
||||
<span class="text" v-else-if="slots.default">
|
||||
<span><slot /></span>
|
||||
<span><slot /></span>
|
||||
</span>
|
||||
|
||||
<slot />
|
||||
<span v-if="loading" class="loading">
|
||||
<svg-util-spinner />
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgBtnOutline from '@/components/svg/BtnOutline.vue'
|
||||
import { useSlots, computed, resolveComponent } from 'vue'
|
||||
import SvgUtilSpinner from '@/components/svg/util/Spinner.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
// Will render ADiv
|
||||
href: [Object, String],
|
||||
// Will render PrismicLink
|
||||
linkField: Object,
|
||||
tag: String,
|
||||
size: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
default: () => 'small',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
secondary: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
number: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
hover: {
|
||||
type: Boolean,
|
||||
default: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const component = computed(() => {
|
||||
if (props.tag) return props.tag
|
||||
if (props.href) return 'a'
|
||||
if (props.linkField) return resolveComponent('prismic-link')
|
||||
return 'button'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.btn {
|
||||
text-transform: uppercase;
|
||||
padding: desktop-vw(5px) desktop-vw(16px) desktop-vw(6px);
|
||||
color: var(--grey-100);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: desktop-vw(5px) desktop-vw(20px);
|
||||
border: 1px solid var(--theme-fg);
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--td) var(--te),
|
||||
background var(--td) var(--te);
|
||||
@include text-flip(var(--td), var(--te));
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-accent);
|
||||
@include mobile {
|
||||
padding: mobile-vw(5px) mobile-vw(20px);
|
||||
}
|
||||
|
||||
.text span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: desktop-vw(6px);
|
||||
width: desktop-vw(12px);
|
||||
height: auto;
|
||||
|
||||
@include mobile {
|
||||
margin-left: mobile-vw(6px);
|
||||
width: mobile-vw(12px);
|
||||
}
|
||||
}
|
||||
|
||||
&.size-large {
|
||||
font-size: desktop-vw(28px);
|
||||
padding: desktop-vw(8.7px) desktop-vw(34.7px);
|
||||
|
||||
@include mobile {
|
||||
font-size: mobile-vw(28px);
|
||||
padding: mobile-vw(8.7px) mobile-vw(34.7px);
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--theme-bg);
|
||||
color: var(--theme-fg);
|
||||
}
|
||||
|
||||
&.number {
|
||||
padding: desktop-vw(5px) desktop-vw(10px);
|
||||
|
||||
@include mobile {
|
||||
padding: mobile-vw(5px) mobile-vw(10px);
|
||||
}
|
||||
}
|
||||
|
||||
&.hover {
|
||||
&:hover,
|
||||
&.active,
|
||||
&.router-link-active,
|
||||
&.router-link-exact-active {
|
||||
color: var(--theme-fg);
|
||||
background: var(--theme-bg);
|
||||
|
||||
&.secondary {
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.hover) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
opacity: 0.16;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
76
components/ColumnLayout.vue
Normal file
76
components/ColumnLayout.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<main class="column-layout">
|
||||
<div class="columns">
|
||||
<lenis class="column one" instance="column-one">
|
||||
<slot name="column-one" />
|
||||
</lenis>
|
||||
|
||||
<lenis class="column two" instance="column-two">
|
||||
<slot name="column-two" />
|
||||
</lenis>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Lenis from './Lenis.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.column-layout {
|
||||
/* padding-top: var(--header-height); */
|
||||
/* height: var(--win-height); */
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.breadcrumbs {
|
||||
padding-top: desktop-vw(28px);
|
||||
padding-bottom: desktop-vw(28px);
|
||||
}
|
||||
|
||||
.columns {
|
||||
grid-template-columns: desktop-vw(480px) 1fr;
|
||||
display: grid;
|
||||
border-top: 1px solid var(--theme-fg);
|
||||
|
||||
.column {
|
||||
height: 100vh;
|
||||
overflow: scroll;
|
||||
|
||||
&.one {
|
||||
border-right: 1px solid var(--theme-fg);
|
||||
}
|
||||
&.three {
|
||||
border-left: 1px solid var(--theme-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
height: auto;
|
||||
margin-top: mobile-vw(20px);
|
||||
|
||||
.columns {
|
||||
grid-template-columns: 1fr;
|
||||
padding-bottom: 35vh;
|
||||
|
||||
.column {
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
|
||||
&.one {
|
||||
border-top: 1px solid var(--theme-fg);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 35vh;
|
||||
z-index: 5;
|
||||
background: var(--theme-bg);
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
components/NumberInput.vue
Normal file
76
components/NumberInput.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="number-input">
|
||||
<label>Quantity (Max: {{ max }})</label>
|
||||
|
||||
<div class="input-wrap">
|
||||
<button @click="decrement">-</button>
|
||||
|
||||
<input
|
||||
v-model="model"
|
||||
:id="ID"
|
||||
type="number"
|
||||
:min="min"
|
||||
:max="max"
|
||||
/>
|
||||
|
||||
<button @click="increment">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const ID = Math.random().toString(36).substring(2)
|
||||
|
||||
const props = defineProps({
|
||||
min: {
|
||||
type: Number,
|
||||
default: () => 1,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: () => 10,
|
||||
},
|
||||
})
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
const increment = () => {
|
||||
if (model.value < props.max) {
|
||||
model.value++
|
||||
}
|
||||
}
|
||||
const decrement = () => {
|
||||
if (model.value > props.min) {
|
||||
model.value--
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.number-input {
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: desktop-vw(20px);
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
border: 1px solid var(--theme-fg);
|
||||
|
||||
button {
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
input {
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
components/Slider.vue
Normal file
173
components/Slider.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="slider">
|
||||
<div class="slider-container" ref="emblaNode">
|
||||
<div class="slides">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="controls" class="controls">
|
||||
<button @click="onPrev">
|
||||
<arrow />
|
||||
</button>
|
||||
<button @click="onNext">
|
||||
<arrow />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="nav && slideCount > 1" class="nav">
|
||||
<btn
|
||||
v-for="i in slideCount"
|
||||
@click="onNavClick(i)"
|
||||
:secondary="activeIndex !== i"
|
||||
number
|
||||
>{{ i }}</btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Btn from '@/components/Btn.vue'
|
||||
import Arrow from '@/components/svg/util/Arrow.vue'
|
||||
import embla from 'embla-carousel-vue'
|
||||
import ClassNames from 'embla-carousel-class-names'
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
|
||||
const props = defineProps({
|
||||
emblaOptions: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
autoplay: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
autoplayOptions: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
controls: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
nav: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
slideCount: {
|
||||
type: Number,
|
||||
default: () => 0,
|
||||
},
|
||||
})
|
||||
|
||||
const plugins = computed(() => {
|
||||
const base = [ClassNames()]
|
||||
if (props.autoplay) {
|
||||
base.push(Autoplay(props.autoplayOptions))
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const [emblaNode, emblaApi] = embla(props.emblaOptions, plugins.value)
|
||||
|
||||
defineExpose({
|
||||
api: emblaApi,
|
||||
})
|
||||
|
||||
const activeIndex = ref(1)
|
||||
const onNext = () => {
|
||||
emblaApi.value.scrollNext()
|
||||
}
|
||||
const onPrev = () => {
|
||||
emblaApi.value.scrollPrev()
|
||||
}
|
||||
const onNavClick = (i) => {
|
||||
activeIndex.value = i
|
||||
|
||||
emblaApi.value.scrollTo(i - 1)
|
||||
}
|
||||
|
||||
watch(emblaApi, () => {
|
||||
if (!emblaApi.value) return
|
||||
|
||||
emblaApi.value.on('select', () => {
|
||||
activeIndex.value = emblaApi.value.selectedScrollSnap() + 1
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.slider {
|
||||
position: relative;
|
||||
|
||||
.slider-container {
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slides {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--layout-margin);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
button {
|
||||
pointer-events: all;
|
||||
border-radius: 50%;
|
||||
background: var(--black);
|
||||
color: var(--white);
|
||||
width: desktop-vw(40px);
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: desktop-vw(21px);
|
||||
height: auto;
|
||||
}
|
||||
&:first-child {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
button {
|
||||
width: mobile-vw(32px);
|
||||
|
||||
svg {
|
||||
width: mobile-vw(16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.nav {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: desktop-vw(25px);
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@include mobile {
|
||||
bottom: mobile-vw(25px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
components/event/Info.vue
Normal file
141
components/event/Info.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div v-if="event" class="event-info">
|
||||
<h1 class="title h4">{{ event.title }}</h1>
|
||||
|
||||
<p class="date p-s">{{ date }}</p>
|
||||
|
||||
<prismic-rich-text class="description" :field="event.description" />
|
||||
|
||||
<event-rsvp
|
||||
v-if="rsvpExpanded"
|
||||
class="theme-dark"
|
||||
:event-name="event.title"
|
||||
:event-date="date"
|
||||
@success="rsvpExpanded = false"
|
||||
/>
|
||||
|
||||
<div v-else class="btn-wrap">
|
||||
<btn
|
||||
v-if="type === 'rsvp'"
|
||||
class="cta"
|
||||
:hover="false"
|
||||
@click="rsvpExpanded = true"
|
||||
>
|
||||
RSVP
|
||||
</btn>
|
||||
</div>
|
||||
|
||||
<div v-if="type === 'product' && product" class="purchase-wrap">
|
||||
<number-input
|
||||
v-model="ticketQuantity"
|
||||
:min="1"
|
||||
:max="maxTicketQuantity"
|
||||
/>
|
||||
<btn :href="checkoutUrl" :disabled="!checkoutUrl"
|
||||
>Purchase Ticket</btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { format } from 'fecha'
|
||||
import { ref, computed } from 'vue'
|
||||
import Btn from '@/components/Btn.vue'
|
||||
import NumberInput from '@/components/NumberInput.vue'
|
||||
import EventRsvp from '@/components/event/Rsvp.vue'
|
||||
import PrismicRichText from '@/components/prismic/RichText.vue'
|
||||
import { resolveEventType } from '@/libs/resolveEventType'
|
||||
|
||||
const DEFAULT_MAX_TICKET_QUANTITY = 4
|
||||
|
||||
const props = defineProps({ event: Object, product: Object || undefined })
|
||||
|
||||
const rsvpExpanded = ref(false)
|
||||
const ticketQuantity = ref(1)
|
||||
|
||||
const date = computed(() => {
|
||||
if (!props.event?.date) return ''
|
||||
const d = new Date(props.event.date)
|
||||
return format(d, 'MM.DD.YY')
|
||||
})
|
||||
const type = computed(() => {
|
||||
const prismicType = props.event?.event_type?.trim()
|
||||
|
||||
return resolveEventType(prismicType)
|
||||
})
|
||||
|
||||
// Product checkout
|
||||
const maxTicketQuantity = computed(
|
||||
() => props.event?.max_ticket_quantity || DEFAULT_MAX_TICKET_QUANTITY,
|
||||
)
|
||||
const checkoutUrl = computed(() => {
|
||||
const edges = props.product?.product?.variants?.edges ?? []
|
||||
|
||||
const variantNode = edges
|
||||
.map((edge) => edge?.node)
|
||||
.find((node) => node?.availableForSale)
|
||||
if (!variantNode?.id) return null
|
||||
|
||||
const numericId = variantNode.id.split('/').pop()
|
||||
if (!numericId) return null
|
||||
|
||||
const quantity = Math.max(
|
||||
1,
|
||||
Math.min(ticketQuantity.value ?? 1, maxTicketQuantity.value),
|
||||
)
|
||||
|
||||
return `https://swangent.myshopify.com/cart/${numericId}:${quantity}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.event-info {
|
||||
padding: desktop-vw(60px) var(--layout-margin);
|
||||
|
||||
.title {
|
||||
margin-bottom: desktop-vw(20px);
|
||||
}
|
||||
.date {
|
||||
margin-bottom: desktop-vw(30px);
|
||||
}
|
||||
.description {
|
||||
margin-bottom: desktop-vw(30px);
|
||||
width: desktop-vw(330px);
|
||||
|
||||
p {
|
||||
@include p-xxs;
|
||||
}
|
||||
}
|
||||
.btn-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.purchase-wrap {
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-top: desktop-vw(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
padding: mobile-vw(30px) var(--layout-margin);
|
||||
|
||||
.title {
|
||||
margin-bottom: mobile-vw(20px);
|
||||
}
|
||||
.date {
|
||||
margin-bottom: mobile-vw(30px);
|
||||
}
|
||||
.description {
|
||||
margin-bottom: mobile-vw(30px);
|
||||
width: mobile-vw(330px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
components/event/Recirc.vue
Normal file
27
components/event/Recirc.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="event-recirc">
|
||||
<h3 class="header p-xs">Upcoming Events</h3>
|
||||
|
||||
<event-list-item v-for="event in events" :event="event" type="recirc" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({ events: Array })
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.event-recirc {
|
||||
.header {
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
padding: desktop-vw(6.5px) desktop-vw(14px);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.header {
|
||||
padding: mobile-vw(6.5px) mobile-vw(14px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
components/event/Rsvp.vue
Normal file
119
components/event/Rsvp.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="event-form">
|
||||
<div v-if="success" class="success">
|
||||
<p>Thank you</p>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="onRsvpSubmit">
|
||||
<input v-model="name" type="text" placeholder="Name" required />
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
validate
|
||||
/>
|
||||
<btn type="submit" :loading="loading" secondary>Submit</btn>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import Btn from '@/components/Btn.vue'
|
||||
import useKlaviyo from '@/composables/useKlaviyo'
|
||||
|
||||
const props = defineProps({
|
||||
eventName: String,
|
||||
eventDate: String,
|
||||
})
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const { loading, success, error, subscribe, track } = useKlaviyo()
|
||||
|
||||
// Global "All RSVPs" list ID lives on the Events index doc
|
||||
// const { data: eventsIndex } = await usePrisSingle('events')
|
||||
// const allRsvpsListId = computed(
|
||||
// () => eventsIndex.value?.data?.all_rsvps_klaviyo_list_id,
|
||||
// )
|
||||
const allRsvpsListId = ''
|
||||
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
|
||||
const onRsvpSubmit = async () => {
|
||||
if (allRsvpsListId.value) {
|
||||
await subscribe({
|
||||
listId: allRsvpsListId.value,
|
||||
email: email.value,
|
||||
name: name.value,
|
||||
source: 'website_event_rsvp',
|
||||
})
|
||||
}
|
||||
|
||||
// Append-only event for per-event segmentation in Klaviyo
|
||||
try {
|
||||
await track({
|
||||
email: email.value,
|
||||
metric: 'Submitted RSVP',
|
||||
properties: {
|
||||
event_name: props.eventName,
|
||||
event_date: props.eventDate,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to track RSVP event', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset after success
|
||||
watch(success, () => {
|
||||
if (success.value) {
|
||||
setTimeout(() => {
|
||||
emit('success')
|
||||
success.value = false
|
||||
name.value = ''
|
||||
email.value = ''
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.event-form {
|
||||
width: desktop-vw(390px);
|
||||
height: 100%;
|
||||
|
||||
.success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
height: desktop-vw(78.5px);
|
||||
}
|
||||
|
||||
input {
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: solid var(--theme-bg);
|
||||
border-width: 1px 1px 0 1px;
|
||||
text-align: center;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
border: 1px solid var(--theme-bg);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
|
||||
.success {
|
||||
height: mobile-vw(70px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
components/event/list/Index.vue
Normal file
73
components/event/list/Index.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="event-list">
|
||||
<div class="filter-bar">
|
||||
<h3 class="h4">Upcoming Events</h3>
|
||||
|
||||
<div class="filters">
|
||||
<btn
|
||||
:class="{ active: filter === 'current' }"
|
||||
@click="onFilterClick('current')"
|
||||
>Current</btn
|
||||
>
|
||||
<btn
|
||||
:class="{ active: filter === 'past' }"
|
||||
@click="onFilterClick('past')"
|
||||
>Past</btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<event-list-item
|
||||
v-for="event in filteredEvents"
|
||||
:event="event"
|
||||
@hover="(e) => emit('eventHover', e)"
|
||||
@blur="(e) => emit('eventBlur', e)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({ events: Array })
|
||||
const emit = defineEmits(['eventHover', 'eventBlur'])
|
||||
|
||||
const { filter, filteredEvents } = useFilteredEvents(props.events)
|
||||
|
||||
const onFilterClick = (label) => {
|
||||
navigateTo({
|
||||
query: {
|
||||
filter: label,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.event-list {
|
||||
margin-top: desktop-vw(78px);
|
||||
|
||||
.filter-bar {
|
||||
padding: desktop-vw(20px) var(--layout-margin);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: desktop-vw(15px);
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin-top: mobile-vw(75px);
|
||||
|
||||
.filter-bar {
|
||||
padding: mobile-vw(20px) var(--layout-margin);
|
||||
display: block;
|
||||
|
||||
.filters {
|
||||
margin-top: mobile-vw(15px);
|
||||
gap: mobile-vw(15px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
207
components/event/list/Item.vue
Normal file
207
components/event/list/Item.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="event"
|
||||
:class="[
|
||||
`event-list-item type-${type}`,
|
||||
{ 'mobile-active': mobileActive },
|
||||
]"
|
||||
@mouseenter="emit('hover', event.id)"
|
||||
@mouseleave="emit('blur', event.id)"
|
||||
ref="container"
|
||||
>
|
||||
<prismic-media
|
||||
v-if="type === 'recirc'"
|
||||
class="preview-image"
|
||||
:image="event.preview_image"
|
||||
:video="event.preview_video"
|
||||
desktopSize="25vw"
|
||||
/>
|
||||
|
||||
<p class="title p-l">{{ event.title }}</p>
|
||||
<p class="date p-l">{{ date }}</p>
|
||||
|
||||
<prismic-rich-text
|
||||
v-if="type === 'row'"
|
||||
class="description entry"
|
||||
:field="event.description"
|
||||
/>
|
||||
|
||||
<div v-if="rsvpExpanded && type === 'row'" class="btn-wrap">
|
||||
<event-rsvp
|
||||
:event-name="event.title"
|
||||
:event-date="date"
|
||||
@success="rsvpExpanded = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="btn-wrap">
|
||||
<btn
|
||||
v-if="eventType === 'rsvp' && type === 'row'"
|
||||
class="btn-rsvp"
|
||||
secondary
|
||||
:hover="false"
|
||||
@click="rsvpExpanded = true"
|
||||
>RSVP</btn
|
||||
>
|
||||
|
||||
<smart-link :field="link">
|
||||
<btn secondary :hover="false">{{ link.text || 'View' }}</btn>
|
||||
</smart-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { resolveEventType } from '~/libs/resolveEventType'
|
||||
import { format } from 'fecha'
|
||||
|
||||
const props = defineProps({
|
||||
event: Object,
|
||||
type: {
|
||||
type: String,
|
||||
default: () => 'row',
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['hover', 'blur'])
|
||||
|
||||
const rsvpExpanded = ref(false)
|
||||
|
||||
const date = computed(() => {
|
||||
if (!props.event?.date) return ''
|
||||
const d = new Date(props.event.date)
|
||||
return format(d, 'MM.DD.YY')
|
||||
})
|
||||
const link = computed(() => {
|
||||
return {
|
||||
...props.event,
|
||||
type: 'event',
|
||||
link_type: 'Document',
|
||||
text: 'View Event',
|
||||
}
|
||||
})
|
||||
const eventType = computed(() => {
|
||||
const prismicType = props.event?.event_type?.trim()
|
||||
|
||||
return resolveEventType(prismicType)
|
||||
})
|
||||
|
||||
// Mobile active states
|
||||
const container = ref()
|
||||
const mobileActive = ref(false)
|
||||
useIntersectionObserver(
|
||||
container,
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
emit('hover', props.event.id)
|
||||
mobileActive.value = true
|
||||
} else {
|
||||
emit('blur', props.event.id)
|
||||
mobileActive.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '-70% 0px -30% 0px',
|
||||
threshold: 0,
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.event-list-item {
|
||||
&.type-row {
|
||||
border-top: 1px solid var(--theme-fg);
|
||||
padding: desktop-vw(32px) var(--layout-margin);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
display: grid;
|
||||
|
||||
.title {
|
||||
grid-column: 1 / span 2;
|
||||
width: desktop-vw(600px);
|
||||
}
|
||||
.btn-wrap {
|
||||
grid-column: 5 / span 2;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.smart-link {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.smart-link:not(.has-link) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-rsvp.btn:not(.hover),
|
||||
.has-link .btn:not(.hover) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-rsvp {
|
||||
margin-left: desktop-vw(15px);
|
||||
margin-right: desktop-vw(15px);
|
||||
}
|
||||
@include hover {
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
padding: mobile-vw(30px) var(--layout-margin);
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
.title {
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
}
|
||||
.description {
|
||||
grid-column: 1/-1;
|
||||
margin-top: mobile-vw(31px);
|
||||
width: mobile-vw(280px);
|
||||
}
|
||||
.btn-wrap {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
justify-content: flex-start;
|
||||
margin-top: mobile-vw(31px);
|
||||
}
|
||||
&.mobile-active {
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.type-recirc {
|
||||
grid-template-columns: auto auto 1fr;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
column-gap: desktop-vw(20px);
|
||||
border-bottom: 1px solid var(--theme-fg);
|
||||
|
||||
.preview-image {
|
||||
grid-column: 1/-1;
|
||||
border-top: 1px solid var(--theme-fg);
|
||||
border-bottom: 1px solid var(--theme-fg);
|
||||
}
|
||||
p {
|
||||
@include p-xs;
|
||||
}
|
||||
.btn-wrap {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
|
||||
.btn {
|
||||
border-width: 0 1px 0 0;
|
||||
background: var(--theme-fg);
|
||||
color: var(--theme-bg);
|
||||
}
|
||||
}
|
||||
.date {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
.title {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
components/prismic/Image.vue
Normal file
27
components/prismic/Image.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<img
|
||||
class="prismic-image"
|
||||
:alt="alt"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:src="src"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
field: {
|
||||
type: [String, Object],
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const src = computed(() =>
|
||||
typeof props.field == 'string' ? props.field : props.field?.url,
|
||||
)
|
||||
const width = computed(() => props.field?.width || '')
|
||||
const height = computed(() => props.field?.height || '')
|
||||
const alt = computed(() => props.field?.alt || '')
|
||||
</script>
|
||||
167
components/prismic/Media.vue
Normal file
167
components/prismic/Media.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'prismic-media',
|
||||
{ 'fill-space': fillSpace },
|
||||
`fit-${fit}`,
|
||||
{ 'video-loaded': videoSrc && videoLoaded },
|
||||
]"
|
||||
:style="{ '--aspect': cmpAspect + '%' }"
|
||||
>
|
||||
<div class="image-sizer">
|
||||
<transition :name="transition">
|
||||
<img
|
||||
v-show="loaded"
|
||||
ref="image"
|
||||
:src="imageSrc"
|
||||
:srcset="srcset"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:alt="imageAlt"
|
||||
@load="loaded = true"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<template v-if="videoSrc.length">
|
||||
<video
|
||||
v-show="videoLoaded"
|
||||
:height="height"
|
||||
:width="width"
|
||||
:src="videoSrc"
|
||||
:muted="muted"
|
||||
:autoplay="autoplay"
|
||||
ref="video"
|
||||
playsinline
|
||||
loop
|
||||
@canplay="videoLoaded = true"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
image: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
required: true,
|
||||
},
|
||||
video: [Object, String],
|
||||
aspect: {
|
||||
type: [String, Number],
|
||||
default: () => -1,
|
||||
},
|
||||
transition: {
|
||||
type: String,
|
||||
default: () => 'fade',
|
||||
},
|
||||
fillSpace: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
fit: {
|
||||
type: String,
|
||||
default: () => 'cover',
|
||||
},
|
||||
muted: {
|
||||
type: Boolean,
|
||||
default: () => true,
|
||||
},
|
||||
autoplay: {
|
||||
type: Boolean,
|
||||
default: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const image = ref()
|
||||
const video = ref()
|
||||
const loaded = ref(false)
|
||||
const videoLoaded = ref(false)
|
||||
|
||||
// Set loaded to true if image is in cache
|
||||
onMounted(async () => {
|
||||
await new Promise((res) => setTimeout(res, 50))
|
||||
if (image.value?.complete) loaded.value = true
|
||||
if (video.value?.readyState >= 3) videoLoaded.value = true
|
||||
})
|
||||
|
||||
const imageSrc = computed(() => props.image?.url || '')
|
||||
const imageAlt = computed(() => props.image?.alt || '')
|
||||
const videoSrc = computed(() => props.video?.url || props.video || '')
|
||||
const width = computed(() => props.image?.dimensions?.width || 0)
|
||||
const height = computed(() => props.image?.dimensions?.height || 0)
|
||||
|
||||
const cmpAspect = computed(() => {
|
||||
// calculate if no aspect provided
|
||||
if (props.aspect === -1) {
|
||||
return (height.value / width.value) * 100
|
||||
}
|
||||
|
||||
// otherwise, parse provided aspect, handling both 56.25 and 0.5625 style
|
||||
const toParse = parseFloat(props.aspect)
|
||||
return toParse <= 1 ? toParse * 100 : toParse
|
||||
})
|
||||
const srcset = computed(() => {
|
||||
return [400, 800, 1024, 1280, 1536, 2048, 2560]
|
||||
.map((size) => {
|
||||
const w = size === null ? width.value : size
|
||||
const h = Math.round(width.value / (cmpAspect.value / 100))
|
||||
return imageSrc.value + `&w=${w} ${w}w`
|
||||
})
|
||||
.join(', ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.prismic-media {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.image-sizer {
|
||||
overflow: hidden;
|
||||
padding-bottom: var(--aspect);
|
||||
|
||||
& > * {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// fill space
|
||||
&.fill-space {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
.image-sizer {
|
||||
padding-bottom: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// fits
|
||||
&.fit-cover .image-sizer > * {
|
||||
object-fit: cover;
|
||||
}
|
||||
&.fit-contain .image-sizer > * {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Hide background image on video load */
|
||||
&.video-loaded img {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
components/prismic/RichText.vue
Normal file
17
components/prismic/RichText.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="prismic-rich-text" v-html="html" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import * as prismicH from '@prismicio/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
field: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const html = computed(() => prismicH.asHTML(props.field))
|
||||
</script>
|
||||
50
components/slices/Carousel.vue
Normal file
50
components/slices/Carousel.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<section
|
||||
class="slices-carousel"
|
||||
:data-slice-type="slice.slice_type"
|
||||
:data-slice-variation="slice.variation"
|
||||
>
|
||||
<slider :emblaOptions="{ loop: true }" controls>
|
||||
<prismic-media
|
||||
class="item"
|
||||
v-for="item in items"
|
||||
:image="item.image"
|
||||
:video="item.video"
|
||||
aspect="100"
|
||||
/>
|
||||
</slider>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Slider from '@/components/Slider.vue'
|
||||
import PrismicMedia from '@/components/prismic/Media.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slice: Object,
|
||||
})
|
||||
|
||||
const items = computed(() => props.slice?.primary?.items || [])
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.slices-carousel {
|
||||
margin-top: desktop-vw(100px);
|
||||
margin-bottom: desktop-vw(100px);
|
||||
position: relative;
|
||||
|
||||
.item {
|
||||
width: desktop-vw(640px);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin-top: mobile-vw(100px);
|
||||
margin-bottom: mobile-vw(100px);
|
||||
|
||||
.item {
|
||||
width: mobile-vw(300px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
components/slices/Content.vue
Normal file
57
components/slices/Content.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<section
|
||||
class="slices-content"
|
||||
:data-slice-type="slice.slice_type"
|
||||
:data-slice-variation="slice.variation"
|
||||
>
|
||||
<prismic-rich-text class="body entry" :field="slice.primary.body" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PrismicRichText from '@/components/prismic/RichText.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slice: Object,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.slices-content {
|
||||
margin-top: desktop-vw(31px);
|
||||
margin-bottom: desktop-vw(31px);
|
||||
|
||||
.body {
|
||||
width: desktop-vw(640px);
|
||||
margin: auto;
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: desktop-vw(31px);
|
||||
@include h5;
|
||||
}
|
||||
p,
|
||||
a,
|
||||
ul {
|
||||
text-transform: none;
|
||||
}
|
||||
p {
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin-top: mobile-vw(31px);
|
||||
margin-bottom: mobile-vw(31px);
|
||||
|
||||
.body {
|
||||
width: 100%;
|
||||
padding: 0 var(--layout-margin);
|
||||
|
||||
h2 {
|
||||
margin-bottom: mobile-vw(31px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
components/slices/FullBleedMedia.vue
Normal file
61
components/slices/FullBleedMedia.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<section
|
||||
:class="[
|
||||
'slices-full-bleed-media',
|
||||
{ diptych: items.length === 2, inset: slice.primary.inset },
|
||||
]"
|
||||
:data-slice-type="slice.slice_type"
|
||||
:data-slice-variation="slice.variation"
|
||||
>
|
||||
<prismic-media v-for="item in items" :image="item.image" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import PrismicMedia from '@/components/prismic/Media.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slice: Object,
|
||||
})
|
||||
|
||||
const items = computed(() => props.slice?.primary?.items || [])
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.slices-full-bleed-media {
|
||||
margin-top: desktop-vw(100px);
|
||||
margin-bottom: desktop-vw(100px);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.prismic-media {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.diptych {
|
||||
.prismic-media {
|
||||
border: 1px solid var(--black);
|
||||
}
|
||||
}
|
||||
&.inset {
|
||||
padding: 0 desktop-vw(16px);
|
||||
gap: desktop-vw(26px);
|
||||
|
||||
.prismic-media {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin-top: mobile-vw(100px);
|
||||
margin-bottom: mobile-vw(100px);
|
||||
flex-direction: column;
|
||||
|
||||
&.inset {
|
||||
padding: 0 var(--layout-margin);
|
||||
gap: mobile-vw(29px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
components/slices/Map.vue
Normal file
32
components/slices/Map.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<component
|
||||
v-for="(slice, index) in slices"
|
||||
:key="index"
|
||||
:is="mapComponent(slice)"
|
||||
:slice="slice"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Carousel from '@/components/slices/Carousel.vue'
|
||||
import Content from '@/components/slices/Content.vue'
|
||||
import FullBleedMedia from '@/components/slices/FullBleedMedia.vue'
|
||||
import Quote from '@/components/slices/Quote.vue'
|
||||
|
||||
const MAP = {
|
||||
carousel: Carousel,
|
||||
content: Content,
|
||||
full_bleed_media: FullBleedMedia,
|
||||
quote: Quote,
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
slices: Array,
|
||||
})
|
||||
|
||||
const mapComponent = (slice) => {
|
||||
if (!slice?.slice_type) return
|
||||
|
||||
return MAP[slice.slice_type]
|
||||
}
|
||||
</script>
|
||||
51
components/slices/Quote.vue
Normal file
51
components/slices/Quote.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<section
|
||||
class="slices-quote"
|
||||
:data-slice-type="slice.slice_type"
|
||||
:data-slice-variation="slice.variation"
|
||||
>
|
||||
<div class="quote-wrap">
|
||||
<blockquote class="quote q1">{{ slice.primary.text }}</blockquote>
|
||||
<span class="attribution">
|
||||
— {{ slice.primary.attribution }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
slice: Object,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.slices-quote {
|
||||
margin-top: desktop-size(50px);
|
||||
margin-bottom: desktop-size(50px);
|
||||
|
||||
.quote-wrap {
|
||||
width: desktop-vw(640px);
|
||||
margin: auto;
|
||||
|
||||
.attribution {
|
||||
display: block;
|
||||
margin-top: desktop-vw(13px);
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin-top: mobile-size(50px);
|
||||
margin-bottom: mobile-size(50px);
|
||||
|
||||
.quote-wrap {
|
||||
width: 100%;
|
||||
padding: 0 var(--layout-margin);
|
||||
|
||||
.attribution {
|
||||
margin-top: mobile-vw(13px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<div class="svg-btn-outline">
|
||||
<svg
|
||||
class="side left"
|
||||
width="16"
|
||||
height="28"
|
||||
viewBox="0 0 16 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.7207 0.5H14.7207L0.720634 14.8612L14.7207 27.5H15.7207"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="fill">
|
||||
<div class="borders" />
|
||||
</div>
|
||||
|
||||
<svg
|
||||
class="side right"
|
||||
width="16"
|
||||
height="28"
|
||||
viewBox="0 0 16 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.7207 0.5H14.7207L0.720634 14.8612L14.7207 27.5H15.7207"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.svg-btn-outline {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
.side {
|
||||
height: 100%;
|
||||
|
||||
&.right {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.borders {
|
||||
position: absolute;
|
||||
inset: 0 -1px;
|
||||
border: solid currentColor;
|
||||
border-width: 1px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
21
components/svg/util/Arrow.vue
Normal file
21
components/svg/util/Arrow.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<svg
|
||||
class="svg-arrow"
|
||||
width="23"
|
||||
height="24"
|
||||
viewBox="0 0 23 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10.621 22.3448L20.9658 12L10.621 1.65519"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M20.9658 12L0.276165 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
32
components/svg/util/Spinner.vue
Normal file
32
components/svg/util/Spinner.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<svg
|
||||
class="svg-util-spinner"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
style="margin: auto; display: block"
|
||||
width="18px"
|
||||
height="18px"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
stroke-width="4"
|
||||
stroke="currentColor"
|
||||
stroke-dasharray="62 62"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
repeatCount="indefinite"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="0 50 50;360 50 50"
|
||||
></animateTransform>
|
||||
</circle>
|
||||
</svg>
|
||||
</template>
|
||||
Reference in New Issue
Block a user