Compare commits
69 Commits
@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
*.log*
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
@ -0,0 +1,2 @@
|
|||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
@ -0,0 +1,17 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN apk add bash
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
ENV NITRO_HOST=0.0.0.0
|
||||||
|
ENV NITRO_PORT=5000
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# ENTRYPOINT ["npm", "run", "build", "node", ".output/server/index.mjs"]
|
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white min-w-[800px] min-h-[80vh]">
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage/>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active {
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.page-enter-from,
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(1rem);
|
||||||
|
}
|
||||||
|
</style>
|
After Width: | Height: | Size: 4.1 MiB |
@ -0,0 +1,313 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'CollapseTransition',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'collapse',
|
||||||
|
},
|
||||||
|
dimension: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'height',
|
||||||
|
validator: (value) => {
|
||||||
|
return ['height', 'width'].includes(value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
default: 300,
|
||||||
|
},
|
||||||
|
easing: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'ease-in-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['before-appear', 'appear', 'after-appear', 'appear-cancelled', 'before-enter', 'enter', 'after-enter', 'enter-cancelled', 'before-leave', 'leave', 'after-leave', 'leave-cancelled'],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cachedStyles: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
transition() {
|
||||||
|
const transitions = []
|
||||||
|
|
||||||
|
Object.keys(this.cachedStyles).forEach((key) => {
|
||||||
|
transitions.push(
|
||||||
|
`${this.convertToCssProperty(key)} ${this.duration}ms ${this.easing}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return transitions.join(', ')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
dimension() {
|
||||||
|
this.clearCachedDimensions()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
beforeAppear(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('before-appear', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
appear(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('appear', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
afterAppear(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('after-appear', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
appearCancelled(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('appear-cancelled', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeEnter(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('before-enter', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
enter(el, done) {
|
||||||
|
// Because width and height may be 'auto',
|
||||||
|
// first detect and cache the dimensions
|
||||||
|
this.detectAndCacheDimensions(el)
|
||||||
|
|
||||||
|
// The order of applying styles is important:
|
||||||
|
// - 1. Set styles for state before transition
|
||||||
|
// - 2. Force repaint
|
||||||
|
// - 3. Add transition style
|
||||||
|
// - 4. Set styles for state after transition
|
||||||
|
// If the order is not right and you open any 2nd level submenu
|
||||||
|
// for the first time, the transition will not work.
|
||||||
|
this.setClosedDimensions(el)
|
||||||
|
this.hideOverflow(el)
|
||||||
|
this.forceRepaint(el)
|
||||||
|
this.setTransition(el)
|
||||||
|
this.setOpenedDimensions(el)
|
||||||
|
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('enter', el, done)
|
||||||
|
|
||||||
|
// Call done() when the transition ends
|
||||||
|
// to trigger the @after-enter event.
|
||||||
|
setTimeout(done, this.duration)
|
||||||
|
},
|
||||||
|
|
||||||
|
afterEnter(el) {
|
||||||
|
// Clean up inline styles
|
||||||
|
this.unsetOverflow(el)
|
||||||
|
this.unsetTransition(el)
|
||||||
|
this.unsetDimensions(el)
|
||||||
|
this.clearCachedDimensions()
|
||||||
|
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('after-enter', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
enterCancelled(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('enter-cancelled', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeLeave(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('before-leave', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
leave(el, done) {
|
||||||
|
// For some reason, @leave triggered when starting
|
||||||
|
// from open state on page load. So for safety,
|
||||||
|
// check if the dimensions have been cached.
|
||||||
|
this.detectAndCacheDimensions(el)
|
||||||
|
|
||||||
|
// The order of applying styles is less important
|
||||||
|
// than in the enter phase, as long as we repaint
|
||||||
|
// before setting the closed dimensions.
|
||||||
|
// But it is probably best to use the same
|
||||||
|
// order as the enter phase.
|
||||||
|
this.setOpenedDimensions(el)
|
||||||
|
this.hideOverflow(el)
|
||||||
|
this.forceRepaint(el)
|
||||||
|
this.setTransition(el)
|
||||||
|
this.setClosedDimensions(el)
|
||||||
|
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('leave', el, done)
|
||||||
|
|
||||||
|
// Call done() when the transition ends
|
||||||
|
// to trigger the @after-leave event.
|
||||||
|
// This will also cause v-show
|
||||||
|
// to reapply 'display: none'.
|
||||||
|
setTimeout(done, this.duration)
|
||||||
|
},
|
||||||
|
|
||||||
|
afterLeave(el) {
|
||||||
|
// Clean up inline styles
|
||||||
|
this.unsetOverflow(el)
|
||||||
|
this.unsetTransition(el)
|
||||||
|
this.unsetDimensions(el)
|
||||||
|
this.clearCachedDimensions()
|
||||||
|
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('after-leave', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveCancelled(el) {
|
||||||
|
// Emit the event to the parent
|
||||||
|
this.$emit('leave-cancelled', el)
|
||||||
|
},
|
||||||
|
|
||||||
|
detectAndCacheDimensions(el) {
|
||||||
|
// Cache actual dimensions
|
||||||
|
// only once to void invalid values when
|
||||||
|
// triggering during a transition
|
||||||
|
if (this.cachedStyles)
|
||||||
|
return
|
||||||
|
|
||||||
|
const visibility = el.style.visibility
|
||||||
|
const display = el.style.display
|
||||||
|
|
||||||
|
// Trick to get the width and
|
||||||
|
// height of a hidden element
|
||||||
|
el.style.visibility = 'hidden'
|
||||||
|
el.style.display = ''
|
||||||
|
|
||||||
|
this.cachedStyles = this.detectRelevantDimensions(el)
|
||||||
|
|
||||||
|
// Restore any original styling
|
||||||
|
el.style.visibility = visibility
|
||||||
|
el.style.display = display
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCachedDimensions() {
|
||||||
|
this.cachedStyles = null
|
||||||
|
},
|
||||||
|
|
||||||
|
detectRelevantDimensions(el) {
|
||||||
|
// These properties will be transitioned
|
||||||
|
if (this.dimension === 'height') {
|
||||||
|
return {
|
||||||
|
height: `${el.offsetHeight}px`,
|
||||||
|
paddingTop:
|
||||||
|
el.style.paddingTop || this.getCssValue(el, 'padding-top'),
|
||||||
|
paddingBottom:
|
||||||
|
el.style.paddingBottom || this.getCssValue(el, 'padding-bottom'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dimension === 'width') {
|
||||||
|
return {
|
||||||
|
width: `${el.offsetWidth}px`,
|
||||||
|
paddingLeft:
|
||||||
|
el.style.paddingLeft || this.getCssValue(el, 'padding-left'),
|
||||||
|
paddingRight:
|
||||||
|
el.style.paddingRight || this.getCssValue(el, 'padding-right'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTransition(el) {
|
||||||
|
el.style.transition = this.transition
|
||||||
|
},
|
||||||
|
|
||||||
|
unsetTransition(el) {
|
||||||
|
el.style.transition = ''
|
||||||
|
},
|
||||||
|
|
||||||
|
hideOverflow(el) {
|
||||||
|
el.style.overflow = 'hidden'
|
||||||
|
},
|
||||||
|
|
||||||
|
unsetOverflow(el) {
|
||||||
|
el.style.overflow = ''
|
||||||
|
},
|
||||||
|
|
||||||
|
setClosedDimensions(el) {
|
||||||
|
Object.keys(this.cachedStyles).forEach((key) => {
|
||||||
|
el.style[key] = '0'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setOpenedDimensions(el) {
|
||||||
|
Object.keys(this.cachedStyles).forEach((key) => {
|
||||||
|
el.style[key] = this.cachedStyles[key]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
unsetDimensions(el) {
|
||||||
|
Object.keys(this.cachedStyles).forEach((key) => {
|
||||||
|
el.style[key] = ''
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
forceRepaint(el) {
|
||||||
|
// Force repaint to make sure the animation is triggered correctly.
|
||||||
|
// Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
getComputedStyle(el)[this.dimension]
|
||||||
|
},
|
||||||
|
|
||||||
|
getCssValue(el, style) {
|
||||||
|
return getComputedStyle(el, null).getPropertyValue(style)
|
||||||
|
},
|
||||||
|
|
||||||
|
convertToCssProperty(style) {
|
||||||
|
// Example: convert 'paddingTop' to 'padding-top'
|
||||||
|
// Thanks: https://gist.github.com/tan-yuki/3450323
|
||||||
|
const upperChars = style.match(/([A-Z])/g)
|
||||||
|
|
||||||
|
if (!upperChars)
|
||||||
|
return style
|
||||||
|
|
||||||
|
for (let i = 0, n = upperChars.length; i < n; i++) {
|
||||||
|
style = style.replace(
|
||||||
|
new RegExp(upperChars[i]),
|
||||||
|
`-${upperChars[i].toLowerCase()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.slice(0, 1) === '-')
|
||||||
|
style = style.slice(1)
|
||||||
|
|
||||||
|
return style
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<transition
|
||||||
|
:name="name"
|
||||||
|
@before-appear="beforeAppear"
|
||||||
|
@appear="appear"
|
||||||
|
@after-appear="afterAppear"
|
||||||
|
@appear-cancelled="appearCancelled"
|
||||||
|
@before-enter="beforeEnter"
|
||||||
|
@enter="enter"
|
||||||
|
@after-enter="afterEnter"
|
||||||
|
@enter-cancelled="enterCancelled"
|
||||||
|
@before-leave="beforeLeave"
|
||||||
|
@leave="leave"
|
||||||
|
@after-leave="afterLeave"
|
||||||
|
@leave-cancelled="leaveCancelled"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</transition>
|
||||||
|
</template>
|
@ -0,0 +1,25 @@
|
|||||||
|
import type { Story } from '@storybook/vue3'
|
||||||
|
import Collapsible from './Collapsible.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/Collapsible',
|
||||||
|
component: Collapsible,
|
||||||
|
args: {
|
||||||
|
modelValue: false,
|
||||||
|
title: 'Item',
|
||||||
|
content: 'lorem ipsum dolor sit amet',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: Story = (args, { argTypes }) => ({
|
||||||
|
components: { Collapsible },
|
||||||
|
setup() {
|
||||||
|
return { args, argTypes }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<Collapsible v-bind="args"/>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
||||||
|
Default.args = {}
|
@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
|
import { ref, toRefs, watch } from 'vue'
|
||||||
|
import CollapseTransition from './CollapseTransition.vue'
|
||||||
|
import Modal from '../Modal/Modal.vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
classes?: {
|
||||||
|
wrapper?: string
|
||||||
|
button?: string
|
||||||
|
title?: string
|
||||||
|
panel?: string
|
||||||
|
}
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:modelValue',
|
||||||
|
'change',
|
||||||
|
'toggle',
|
||||||
|
'open',
|
||||||
|
'close',
|
||||||
|
])
|
||||||
|
|
||||||
|
const { modelValue } = toRefs(props)
|
||||||
|
const isOpen = ref(modelValue.value)
|
||||||
|
|
||||||
|
watch(modelValue, (val) => {
|
||||||
|
isOpen.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isOpen, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
emit('change', val)
|
||||||
|
|
||||||
|
if (val)
|
||||||
|
emit('open')
|
||||||
|
else
|
||||||
|
emit('close')
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
emit('toggle')
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Disclosure v-slot="{ open }" as="div">
|
||||||
|
<DisclosureButton
|
||||||
|
class="
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-between
|
||||||
|
w-full
|
||||||
|
text-left
|
||||||
|
rounded-lg
|
||||||
|
focus:outline-none
|
||||||
|
focus-visible:ring
|
||||||
|
focus-visible:ring-blue-50
|
||||||
|
focus-visible:ring-opacity-75
|
||||||
|
"
|
||||||
|
:class="classes?.button"
|
||||||
|
type="button"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<div class="inline-flex w-full">
|
||||||
|
<Icon
|
||||||
|
name="heroicons:chevron-down"
|
||||||
|
:class="isOpen ? 'transform rotate-180' : ''"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
|
<slot name="title"></slot>
|
||||||
|
</div>
|
||||||
|
</DisclosureButton>
|
||||||
|
<CollapseTransition>
|
||||||
|
<div v-show="isOpen">
|
||||||
|
<DisclosurePanel static class="pb-2 text-15" :class="classes?.panel">
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</DisclosurePanel>
|
||||||
|
</div>
|
||||||
|
</CollapseTransition>
|
||||||
|
</Disclosure>
|
||||||
|
</template>
|
@ -0,0 +1,38 @@
|
|||||||
|
import type { Story } from '@storybook/vue3'
|
||||||
|
import CollapsibleGroup from './CollapsibleGroup.vue'
|
||||||
|
|
||||||
|
const genItems = (length = 5): any[] =>
|
||||||
|
Array.from({ length }, (_, v) => ({
|
||||||
|
title: `Item ${v + 1}`,
|
||||||
|
content: `lorem ipsum ${v + 1}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const items = genItems(5)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/CollapsibleGroup',
|
||||||
|
component: CollapsibleGroup,
|
||||||
|
args: {
|
||||||
|
modelValue: false,
|
||||||
|
accordion: false,
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: Story = (args, { argTypes }) => ({
|
||||||
|
components: { CollapsibleGroup },
|
||||||
|
setup() {
|
||||||
|
return { args, argTypes }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<CollapsibleGroup v-bind="args"/>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
||||||
|
Default.args = {}
|
||||||
|
|
||||||
|
export const Accordion = Template.bind({})
|
||||||
|
Accordion.args = {
|
||||||
|
accordion: true,
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
|
import { ref, toRefs, watch } from 'vue'
|
||||||
|
import Icon from '../Icon/index.vue'
|
||||||
|
import Collapsible from './Collapsible.vue'
|
||||||
|
|
||||||
|
interface CollapsibleItem {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
isOpen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props
|
||||||
|
= defineProps<{
|
||||||
|
items?: CollapsibleItem[]
|
||||||
|
classes?: {
|
||||||
|
wrapper?: string
|
||||||
|
button?: string
|
||||||
|
title?: string
|
||||||
|
panel?: string
|
||||||
|
}
|
||||||
|
accordion?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { items } = toRefs(props)
|
||||||
|
|
||||||
|
const children = ref(props.items)
|
||||||
|
|
||||||
|
watch(items, (val) => {
|
||||||
|
children.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
const onToggle = (item: CollapsibleItem) => {
|
||||||
|
if (props.accordion) {
|
||||||
|
children.value.forEach((child) => {
|
||||||
|
child.isOpen = false
|
||||||
|
})
|
||||||
|
item.isOpen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full p-2" :class="classes?.wrapper">
|
||||||
|
<slot>
|
||||||
|
<Collapsible
|
||||||
|
v-for="(item, idx) in children"
|
||||||
|
:key="idx"
|
||||||
|
v-bind="item"
|
||||||
|
v-model="item.isOpen"
|
||||||
|
@toggle="onToggle(item)"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<Swiper
|
||||||
|
:autoHeight="true"
|
||||||
|
:loop="true"
|
||||||
|
:spaceBetween="30"
|
||||||
|
:centeredSlides="true"
|
||||||
|
:autoplay="{
|
||||||
|
delay: 6000,
|
||||||
|
disableOnInteraction: false,
|
||||||
|
pauseOnMouseEnter: true
|
||||||
|
}"
|
||||||
|
:effect="'fade'"
|
||||||
|
:fadeEffect="{
|
||||||
|
crossFade: true
|
||||||
|
}"
|
||||||
|
:pagination="{
|
||||||
|
clickable: true,
|
||||||
|
}"
|
||||||
|
|
||||||
|
:style="{
|
||||||
|
'--swiper-navigation-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-bottom': '0%'
|
||||||
|
}"
|
||||||
|
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation, SwiperEffectFade]"
|
||||||
|
>
|
||||||
|
|
||||||
|
<SwiperSlide v-for="(item, index) in upcoming_events" class="bg-zinc-100 h-full">
|
||||||
|
<div class="gap-1 w-[100%] mt-1 mb-1 text-sm h-full">
|
||||||
|
<div>
|
||||||
|
{{ item.formatted_date }}: {{item.venue.city}}, {{item.venue.state}}
|
||||||
|
<div class="text-[#7F7F7F]">
|
||||||
|
{{ item.venue.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Comment
|
||||||
|
<div v-for="performance in item.program">
|
||||||
|
<div class="italic text-sm ml-16 pt-1">{{performance.work}}</div>
|
||||||
|
<div v-if="performance.ensemble" class="ml-20">
|
||||||
|
{{ performance.ensemble }}
|
||||||
|
</div>
|
||||||
|
<div v-for="performer in performance.performers" class="ml-20">
|
||||||
|
{{ performer.name }} -
|
||||||
|
<span v-for="(instrument, index) in performer.instrument_tags">
|
||||||
|
<span v-if="index !== 0">, </span>
|
||||||
|
{{ instrument }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</SwiperSlide>
|
||||||
|
|
||||||
|
</Swiper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['upcoming_events']
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex p-1 min-w-[25px]">
|
||||||
|
<div v-show="visible" class="bg-black rounded-full text-xs inline-flex" >
|
||||||
|
|
||||||
|
<NuxtLink @click.native="audioPlayerStore.setSoundCloudTrackID(work.soundcloud_trackid)" v-if="type === 'score'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="ion:book-sharp" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'document'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="ion:book-sharp" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'buy'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="bxs:purchase-tag" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'email'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="ic:baseline-email" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'discogs'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="simple-icons:discogs" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<button @click="audioPlayerStore.setSoundCloudTrackID(work.soundcloud_trackid)" v-else-if="type === 'audio'" class="inline-flex p-1">
|
||||||
|
<Icon name="wpf:speaker" color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="modalStore.setModalProps('video', 'aspect-video', true, '', '', work.vimeo_trackid)" v-else-if="type === 'video'" class="inline-flex p-1">
|
||||||
|
<Icon name="fluent:video-48-filled" color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'images', work.gallery, '')" v-else="type === 'image'" class="inline-flex p-1">
|
||||||
|
<Icon name="mdi:camera" color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['type', 'work', 'visible', 'link']
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<Swiper
|
||||||
|
:autoHeight="true"
|
||||||
|
:loop="true"
|
||||||
|
:spaceBetween="30"
|
||||||
|
:centeredSlides="true"
|
||||||
|
:autoplay="{
|
||||||
|
delay: 4000,
|
||||||
|
disableOnInteraction: false,
|
||||||
|
pauseOnMouseEnter: true
|
||||||
|
}"
|
||||||
|
:pagination="{
|
||||||
|
clickable: true,
|
||||||
|
}"
|
||||||
|
:navigation="true"
|
||||||
|
:style="{
|
||||||
|
'--swiper-navigation-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-bottom': 'auto',
|
||||||
|
'--swiper-pagination-top': '1rem',
|
||||||
|
'--swiper-navigation-top-offset': '5rem'
|
||||||
|
}"
|
||||||
|
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation]"
|
||||||
|
>
|
||||||
|
|
||||||
|
<SwiperSlide v-for="image in gallery" class="p-10 bg-zinc-100">
|
||||||
|
<nuxt-img :src="'https://unboundedpress.org/api/' + bucket + '.files/' + image.image_id + '/binary'"
|
||||||
|
quality="50"/>
|
||||||
|
</SwiperSlide>
|
||||||
|
</Swiper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['gallery', 'bucket']
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-black/50 z-15 transition duration-300" />
|
||||||
|
</template>
|
@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
TransitionChild,
|
||||||
|
TransitionRoot,
|
||||||
|
} from '@headlessui/vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
persistent?: boolean
|
||||||
|
fullscreen?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: false,
|
||||||
|
persistent: false,
|
||||||
|
fullscreen: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { modelValue } = toRefs(props)
|
||||||
|
|
||||||
|
const isOpen = ref(modelValue.value)
|
||||||
|
|
||||||
|
watch(modelValue, (value) => {
|
||||||
|
isOpen.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModalClose() {
|
||||||
|
if (!props.persistent)
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
isOpen,
|
||||||
|
open: openModal,
|
||||||
|
close: closeModal,
|
||||||
|
}
|
||||||
|
|
||||||
|
provide('modal', api)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<slot name="activator" :open="openModal" :on="{ click: openModal }" />
|
||||||
|
|
||||||
|
<TransitionRoot appear :show="isOpen" as="template">
|
||||||
|
<Dialog as="div" class="relative z-20" @close="onModalClose">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
class="flex min-h-full items-center justify-center text-center"
|
||||||
|
:class="{
|
||||||
|
'p-4': !fullscreen,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0 scale-95"
|
||||||
|
enter-to="opacity-100 scale-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100 scale-100"
|
||||||
|
leave-to="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel
|
||||||
|
class="w-full transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all"
|
||||||
|
:class="{
|
||||||
|
'h-screen': fullscreen,
|
||||||
|
'max-w-[85vw] rounded-lg': !fullscreen,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
|
||||||
|
</template>
|
@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogDescription } from '@headlessui/vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription class="px-4 py-3 text-sm text-gray-800">
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// import { ref } from 'vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogTitle } from '@headlessui/vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dismissable?: boolean
|
||||||
|
titleClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const api = inject('modal')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
as="div"
|
||||||
|
class="flex gap-2 justify-between items-center px-4 pt-3"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-lg font-medium leading-6 text-gray-900"
|
||||||
|
:class="titleClass"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
<slot v-if="dismissable" name="dismissable">
|
||||||
|
<button
|
||||||
|
class="text-2xl text-gray-500 appearance-none px-2 -mr-2"
|
||||||
|
@click="api.close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[63%,35%] w-full font-thin sticky top-0 bg-white p-2 z-20">
|
||||||
|
<div>
|
||||||
|
<div class="text-5xl p-2"> <NuxtLink to='/'>michael winter</NuxtLink></div>
|
||||||
|
<div class="inline-flex text-2xl ml-4">
|
||||||
|
<NuxtLink class="px-3" to='/'>works</NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='/events'>events</NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='/about'>about</NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='https://unboundedpress.org/code'>code</NuxtLink>
|
||||||
|
<NuxtLink class="px-3 block" to='https://unboundedpress.org/legacy'>legacy</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- hdp link while active -->
|
||||||
|
<!------
|
||||||
|
<div class="inline-flex text-2xl ml-4 font-bold">
|
||||||
|
<NuxtLink class="px-3" to='/a_history_of_the_domino_problem'>A HISTORY OF THE DOMINO PROBLEM | 17.11 - 01.12.2023 </NuxtLink>
|
||||||
|
</div>
|
||||||
|
--->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: this needs to be automatically flipped off when there are no upcoming events-->
|
||||||
|
<!------
|
||||||
|
<div class="px-1 bg-zinc-100 rounded-lg text-center">
|
||||||
|
<div class="text-sm">upcoming events</div>
|
||||||
|
<EventSlider :upcoming_events="upcoming_events" class="max-w-[95%] min-h-[80%]"></EventSlider>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<slot /> <!-- required here only -->
|
||||||
|
<div class="fixed bottom-0 bg-white p-2 w-full flex justify-center z-20">
|
||||||
|
<iframe width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay" v-if="audioPlayerStore.soundcloud_trackid !== 'undefined'"
|
||||||
|
:src="'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + audioPlayerStore.soundcloud_trackid + '&inverse=false&auto_play=true&show_user=false'"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-model="modalStore.isOpen">
|
||||||
|
<ModalBody :class="modalStore.aspect">
|
||||||
|
<ImageSlider v-if="modalStore.type === 'image'" :bucket="modalStore.bucket" :gallery="modalStore.gallery"></ImageSlider>
|
||||||
|
<iframe v-if="modalStore.type === 'video'" :src="'https://player.vimeo.com/video/' + modalStore.vimeo_trackid" width="100%" height="100%" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
if(route.params.files == 'scores') {
|
||||||
|
const { data: work } = await useFetch('https://unboundedpress.org/api/works?filter={"score":"' + route.params.filename + '"}', {
|
||||||
|
transform: (work) => {
|
||||||
|
|
||||||
|
if(work[0].soundcloud_trackid){
|
||||||
|
audioPlayerStore.setSoundCloudTrackID(work[0].soundcloud_trackid)
|
||||||
|
} else {
|
||||||
|
audioPlayerStore.clearSoundCloudTrackID()
|
||||||
|
}
|
||||||
|
return work[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//const today = Date.now(); //annoying this does not work
|
||||||
|
const today = 1715247305793;
|
||||||
|
const { data: upcoming_events } = await useFetch("https://unboundedpress.org/api/events?filter={'start_date':{'$gte':{'$date':" + today + "}}}", {
|
||||||
|
transform: (upcoming_events) => {
|
||||||
|
for (const event of upcoming_events) {
|
||||||
|
let date = new Date(event.start_date.$date)
|
||||||
|
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
||||||
|
}
|
||||||
|
return upcoming_events.sort((a,b) => a.start_date.$date - b.start_date.$date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
@ -0,0 +1,35 @@
|
|||||||
|
//import { defineNuxtConfig } from 'nuxt3'
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: ['@nuxtjs/tailwindcss', '@nuxt/image', 'nuxt-icon', '@pinia/nuxt', 'nuxt-headlessui', 'nuxt-swiper'],
|
||||||
|
extends: ['nuxt-umami'],
|
||||||
|
image: {
|
||||||
|
domains: ['unboundedpress.org']
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
//baseURL: "/dev/",
|
||||||
|
pageTransition: { name: 'page', mode: 'out-in' },
|
||||||
|
head: {
|
||||||
|
viewport: 'width=device-width'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appConfig: {
|
||||||
|
umami: {
|
||||||
|
id: '51f4f246-9c2e-4a86-9ffb-7a7967d9013d',
|
||||||
|
host: 'https://analytics.umami.is/',
|
||||||
|
version: 2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routeRules: {
|
||||||
|
'/cv': { redirect: '/legacy/cv' },
|
||||||
|
'/works_list': { redirect: '/legacy/works_list' },
|
||||||
|
'/hdp': { redirect: '/a_history_of_the_domino_problem' },
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
prerender: { crawlLinks: true}
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
payloadExtraction: true
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "nuxt-app",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/image": "^1.0.0-rc.1",
|
||||||
|
"@nuxtjs/tailwindcss": "^6.7.0",
|
||||||
|
"@types/node": "^18",
|
||||||
|
"nuxt-headlessui": "^1.1.4",
|
||||||
|
"nuxt-icon": "^0.4.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nuxt": "^3.6.0",
|
||||||
|
"@pinia/nuxt": "^0.4.11",
|
||||||
|
"nuxt-swiper": "^1.1.0",
|
||||||
|
"nuxt-umami": "^2.4.2",
|
||||||
|
"pinia": "^2.1.3",
|
||||||
|
"sharp": "^0.32.1"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-full items-center justify-center text-center">
|
||||||
|
<embed v-if="route.params.filename.split('.').pop()==='pdf'" :src="'https://unboundedpress.org/api/' + route.params.files + '.files/' + file_metadata._id.$oid + '/binary'" class="w-[85%] h-[88vh]"/>
|
||||||
|
<nuxt-img v-else-if="route.params.filename.split('.').pop()==='jpg'" :src="'https://unboundedpress.org/api/' + route.params.files + '.files/' + file_metadata._id.$oid + '/binary'" class="w-[85%]"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const { data: file_metadata } = await useFetch('https://unboundedpress.org/api/' + route.params.files + '.files?filter={"filename":"' + route.params.filename + '"}', {
|
||||||
|
//lazy: true,
|
||||||
|
//server: false,
|
||||||
|
transform: (file_metadata) => {
|
||||||
|
return file_metadata[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Files - ' + route.params.filename
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-zinc-100 rounded-lg m-5 grid grid-cols-[60%,35%] gap-10 divide-x divide-solid divide-black py-4 min-h-[calc(100vh-10.5rem)]">
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">about</p>
|
||||||
|
|
||||||
|
<div class="leading-tight py-2 ml-3 text-sm">
|
||||||
|
<div class="leading-tight py-2">
|
||||||
|
My practice as a composer and sound artist is diverse, ranging from music created by digital and acoustic instruments to installations and kinetic sculptures. Each piece typically explores one simple process and often reflects various related interests of mine such as phenomenology, mathematics, epistemology, algorithmic information theory, and the history of science. To me, everything we experience is computable. Given this digital philosophy, I acknowledge even my most open works as algorithmic; and, while not always apparent on the surface of any given piece, the considerations of computability and epistemology are integral to my practice. I often reconcile epistemological limits with artistic practicality by considering and addressing the limits of computation from an artistic and experiential vantage point and by collaborating with other artists, mathematicians, and scientists in order to integrate objects, ideas, and texts from various domains as structural elements in my pieces. My work also aims to subvert discriminatory conventions and hierarchies by exploring alternative forms of presentation and interaction, often with minimal resources and low information.
|
||||||
|
</div>
|
||||||
|
<div class="leading-tight py-2">
|
||||||
|
My work has been presented at venues and festivals throughout the world such as REDCAT, in Los Angeles; the Ostrava Festival of New Music in the Czech Republic; Tsonami Arte Sonoro Festival in Valparaiso, Chile; the Huddersfield New Music Festival in the United Kingdom; and Umbral Sesiones at the Museo de Arte Contemporáneo in Oaxaca, Mexico. Recordings of my music have been released by XI Records, Another Timbre, New World Records, Edition Wandelweiser, Bahn Mi Verlag, Tsonami Records, and Pogus Productions. In 2008, I co-founded <em>the wulf.</em>, a Los Angeles-based organization dedicated to experimental performance and art. From 2018 to 2019, I was a fellow / artist-in-residence at the Akademie Schloss Solitude in Stuttgart, Germany. I currently reside in Berlin.
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<div id="mc_embed_signup">
|
||||||
|
<form action="https://unboundedpress.us12.list-manage.com/subscribe/post?u=bdadd25738fedf704641f3a80&id=01c5761ebb&f_id=00f143e0f0" method="post" id="mc-embedded-subscribe-form" name="mc-embedded-subscribe-form" class="validate" target="_self">
|
||||||
|
<label for="mce-EMAIL">subscribe to my mailing list to know about upcoming events</label>
|
||||||
|
<input id="mce-EMAIL" type="email" value="" name="EMAIL" placeholder="email address" required="" class="email">
|
||||||
|
<div style="position: absolute; left: -5000px;" aria-hidden="true">
|
||||||
|
<input type="text" name="b_bdadd25738fedf704641f3a80_01c5761ebb" tabindex="-1" value="">
|
||||||
|
</div>
|
||||||
|
<div id="mce-responses" class="clear foot">
|
||||||
|
<div class="response" id="mce-error-response" style="display:none"></div>
|
||||||
|
<div class="response" id="mce-success-response" style="display:none"></div>
|
||||||
|
</div> <!-- real people should not fill this in and expect good things - do not remove this or risk form bot signups-->
|
||||||
|
<div class="clear">
|
||||||
|
<input id="mc-embedded-subscribe" type="submit" value="subscribe" name="subscribe" class="button">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<div class="inline-flex place-items-center p-2">
|
||||||
|
Contact
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="true" type="email" work="placeholder" link="javascript:location='mailto:\u006d\u0077\u0069\u006e\u0074\u0065\u0072\u0040\u0075\u006e\u0062\u006f\u0075\u006e\u0064\u0065\u0064\u0070\u0072\u0065\u0073\u0073\u002e\u006f\u0072\u0067';void 0"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="inline-flex place-items-center p-2">
|
||||||
|
CV
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="true" type="document" work="placeholder" link="https://unboundedpress.org/legacy/cv"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="inline-flex place-items-center p-2">
|
||||||
|
Works List with Presentation History
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="true" type="document" work="placeholder" link="https://unboundedpress.org/legacy/works_list"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5">
|
||||||
|
<ImageSlider bucket="images" :gallery="gallery" class="max-w-[90%]"></ImageSlider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
|
||||||
|
const { data: images } = await useFetch('https://unboundedpress.org/api/images.files?pagesize=200')
|
||||||
|
|
||||||
|
const { data: gallery } = await useFetch('https://unboundedpress.org/api/my_image_gallery?pagesize=200', {
|
||||||
|
transform: (gallery) => {
|
||||||
|
for (const item of gallery) {
|
||||||
|
item.image_id = images.value.find(obj => {return obj.filename === item.image})._id.$oid
|
||||||
|
}
|
||||||
|
return gallery //.sort((a,b) => a.priority - b.priority)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - About - Short Bio, Contact, CV, Works List, and Mailing List'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#mc_embed_signup form {text-align:left; padding:2px 0 2px 0;}
|
||||||
|
.mc-field-group { display: inline-block; } /* positions input field horizontally */
|
||||||
|
#mc_embed_signup input.email {border: 1px solid #ABB0B2; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; color: #343434; background-color: #fff; box-sizing:border-box; height:32px; padding: 0px 0.4em; display: inline-block; margin: 0; width:350px; vertical-align:top;}
|
||||||
|
#mc_embed_signup label {display:block; padding-bottom:10px;}
|
||||||
|
#mc_embed_signup .clear {display: inline-block;} /* positions button horizontally in line with input */
|
||||||
|
#mc_embed_signup .button {font-size: 13px; border: none; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; letter-spacing: .03em; color: #fff; background-color: #aaa; box-sizing:border-box; height:32px; line-height:32px; padding:0 18px; display: inline-block; margin: 0; transition: all 0.23s ease-in-out 0s;}
|
||||||
|
#mc_embed_signup .button:hover {background-color:#777; cursor:pointer;}
|
||||||
|
#mc_embed_signup div#mce-responses {float:left; top:-1.4em; padding:0em .5em 0em .5em; overflow:hidden; width:90%;margin: 0 5%; clear: both;}
|
||||||
|
#mc_embed_signup div.response {margin:1em 0; padding:1em .5em .5em 0; font-weight:bold; float:left; top:-1.5em; z-index:1; width:80%;}
|
||||||
|
#mc_embed_signup #mce-error-response {display:none;}
|
||||||
|
#mc_embed_signup #mce-success-response {color:#529214; display:none;}
|
||||||
|
#mc_embed_signup label.error {display:block; float:none; width:auto; margin-left:1.05em; text-align:left; padding:.5em 0;}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#mc_embed_signup input.email {width:100%; margin-bottom:5px;}
|
||||||
|
#mc_embed_signup .clear {display: block; width: 100% }
|
||||||
|
#mc_embed_signup .button {width: 100%; margin:0; }
|
||||||
|
}
|
||||||
|
#mc_embed_signup{clear:left; width:100%;}
|
||||||
|
</style>
|
@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-zinc-100 rounded-lg m-5 grid grid-cols-2 gap-10 divide-x divide-solid divide-black py-4 mb-10">
|
||||||
|
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">performances</p>
|
||||||
|
|
||||||
|
<div v-for="(item, index) in events">
|
||||||
|
<Collapsible title='placeholder' :modelValue='index <= 10' class="leading-tight py-2 ml-3 text-sm">
|
||||||
|
<template v-slot:title>
|
||||||
|
<div class="gap-1 w-[95%] px-2">
|
||||||
|
<div>
|
||||||
|
{{ item.formatted_date }}: {{item.venue.city}}, {{item.venue.state}}
|
||||||
|
<div class="ml-4 text-[#7F7F7F]">
|
||||||
|
{{ item.venue.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:content>
|
||||||
|
<div v-for="performance in item.program">
|
||||||
|
<div class="italic text-sm ml-16 pt-1">{{performance.work}}</div>
|
||||||
|
<div v-if="performance.ensemble" class="ml-20">
|
||||||
|
{{ performance.ensemble }}
|
||||||
|
</div>
|
||||||
|
<div v-for="performer in performance.performers" class="ml-20">
|
||||||
|
{{ performer.name }} -
|
||||||
|
<span v-for="(instrument, index) in performer.instrument_tags">
|
||||||
|
<span v-if="index !== 0">, </span>
|
||||||
|
{{ instrument }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="italic text-sm ml-16 pt-1">{{item.legacy_program}}</div>
|
||||||
|
<div class="ml-20">{{item.legacy_performers}}</div>
|
||||||
|
</template>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">lectures</p>
|
||||||
|
|
||||||
|
<div class="leading-tight py-2 ml-3 text-sm" v-for="item in lectures">
|
||||||
|
<div class="gap-1">
|
||||||
|
<div>
|
||||||
|
{{ item.formatted_date }}: {{item.location}}
|
||||||
|
<div v-for="talk in item.talks" class="ml-4 text-[#7F7F7F]">
|
||||||
|
{{ talk.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const { data: events } = await useFetch('https://unboundedpress.org/api/events?pagesize=200', {
|
||||||
|
transform: (events) => {
|
||||||
|
for (const event of events) {
|
||||||
|
let date = new Date(event.start_date.$date)
|
||||||
|
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
||||||
|
}
|
||||||
|
return events.sort((a,b) => b.start_date.$date - a.start_date.$date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: lectures } = await useFetch('https://unboundedpress.org/api/talks?pagesize=200', {
|
||||||
|
transform: (events) => {
|
||||||
|
for (const event of events) {
|
||||||
|
let date = new Date(event.date)
|
||||||
|
event.date = date
|
||||||
|
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
||||||
|
if(typeof event.title === 'string' || event.title instanceof String) {event.talks = [{'title': event.title}]
|
||||||
|
} else {
|
||||||
|
let talks = []
|
||||||
|
for(const talk of event.title){
|
||||||
|
talks.push({"title": talk})
|
||||||
|
}
|
||||||
|
event.talks = talks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events.sort((a,b) => b.date - a.date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Events - Performances and Lectures'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-zinc-100 rounded-lg m-5 grid grid-cols-3 gap-10 divide-x divide-solid divide-black py-4 mb-10">
|
||||||
|
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">pieces</p>
|
||||||
|
|
||||||
|
<div class="py-2 ml-3" v-for="item in works">
|
||||||
|
<p class="font-thin">{{ item.year }}</p>
|
||||||
|
<div class="leading-tight py-1 ml-3" v-for="work in item.works">
|
||||||
|
<div class="grid grid-cols-[65%,30%] gap-1 font-thin">
|
||||||
|
<div class="italic text-sm">{{ work.title }}</div>
|
||||||
|
<div class="inline-flex">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="work.score" type="score" :work="work" :link="work.score" class="inline-flex p-1"></IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="work.soundcloud_trackid" type="audio" :work="work" class="inline-flex p-1"></IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="work.vimeo_trackid" type="video" :work="work" class="inline-flex p-1"></IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="work.gallery" type="image" :work="work" class="inline-flex p-1"></IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">writings</p>
|
||||||
|
|
||||||
|
<div class="leading-tight py-2 ml-3 text-sm" v-for="item in pubs">
|
||||||
|
<div class="grid grid-cols-[95%,5%] gap-1">
|
||||||
|
<div>
|
||||||
|
<span v-html="item.entryTags.title"></span>
|
||||||
|
<div class="ml-4 text-[#7F7F7F]">
|
||||||
|
{{ item.entryTags.author }}
|
||||||
|
<span v-if=item.entryTags.booktitle>{{ item.entryTags.booktitle}}. </span>
|
||||||
|
<span v-if=item.entryTags.journal>{{item.entryTags.journal}}. </span>
|
||||||
|
<span v-if=item.entryTags.editor>editors {{item.entryTags.editor}} </span>
|
||||||
|
<span v-if=item.entryTags.volume>volume {{item.entryTags.volume}}.</span>
|
||||||
|
<span v-if=item.entryTags.publisher>{{item.entryTags.publisher}}.</span>
|
||||||
|
{{ item.entryTags.year }}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<IconButton :visible=item.entryTags.howpublished type="document" :link="item.entryTags.howpublished" class="inline-flex p-1"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">albums</p>
|
||||||
|
<div class="leading-tight py-4 ml-3 text-sm" v-for="item in releases">
|
||||||
|
<p class="text-center leading-tight py-2">{{ item.title }}</p>
|
||||||
|
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image_id: item.album_art_id}], '')">
|
||||||
|
<nuxt-img :src="'https://unboundedpress.org/api/album_art.files/' + item.album_art_id + '/binary'"
|
||||||
|
quality="50"/>
|
||||||
|
</button>
|
||||||
|
<div class="flex place-content-center place-items-center">
|
||||||
|
<IconButton :visible="item.discogs_id" type="discogs" :link="'https://www.discogs.com/release/' + item.discogs_id"></IconButton>
|
||||||
|
<IconButton :visible="item.buy_link" type="buy" :link="item.buy_link"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});
|
||||||
|
|
||||||
|
const isValidUrl = urlString => {
|
||||||
|
/*
|
||||||
|
var urlPattern = new RegExp('^(https?:\\/\\/)?'+ // validate protocol
|
||||||
|
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // validate domain name
|
||||||
|
'((\\d{1,3}\\.){3}\\d{1,3}))'+ // validate OR ip (v4) address
|
||||||
|
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // validate port and path
|
||||||
|
'(\\?[;&a-z\\d%_.~+=-]*)?'+ // validate query string
|
||||||
|
'(\\#[-a-z\\d_]*)?$','i'); // validate fragment locator
|
||||||
|
return !!urlPattern.test(urlString);
|
||||||
|
*/
|
||||||
|
|
||||||
|
var pattern = /^((http|https|ftp):\/\/)/;
|
||||||
|
return pattern.test(urlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const { data: images } = await useFetch('https://unboundedpress.org/api/images.files?pagesize=200')
|
||||||
|
|
||||||
|
const { data: works } = await useFetch('https://unboundedpress.org/api/works?pagesize=200', {
|
||||||
|
transform: (works) => {
|
||||||
|
for (const work of works) {
|
||||||
|
if(work.score){
|
||||||
|
work.score = "/scores/" + work.score
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
if(work.images){
|
||||||
|
let image_ids = [];
|
||||||
|
for (const image of work.images){
|
||||||
|
image_ids.push(images.value.find(obj => {return obj.filename === image.filename})._id.$oid)
|
||||||
|
}
|
||||||
|
work.image_ids = image_ids
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
if(work.images){
|
||||||
|
let gallery = [];
|
||||||
|
for (const image of work.images){
|
||||||
|
gallery.push({
|
||||||
|
image_id: images.value.find(obj => {return obj.filename === image.filename})._id.$oid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
work.gallery = gallery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let priorityGroups = groupBy(works, work => work.priority)
|
||||||
|
let groups = groupBy(priorityGroups["1"], work => new Date(work.date.$date).getFullYear())
|
||||||
|
groups = Object.keys(groups).map((year) => {
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
works: groups[year].sort((a,b) => b.date.$date - a.date.$date)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
groups.sort((a,b) => b.year - a.year)
|
||||||
|
groups.push({year: "miscellany", works: priorityGroups["2"].sort((a,b) => b.date.$date - a.date.$date)})
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
//const { data: pubs } = await useFetch('https://unboundedpress.org/api/publications/_aggrs/publications?pagesize=200')
|
||||||
|
//const { data: pubs } = await useFetch('https://unboundedpress.org/api/publications?sort=-entryTags.year&pagesize=200')
|
||||||
|
const { data: pubs } = await useFetch('https://unboundedpress.org/api/publications?pagesize=200', {
|
||||||
|
transform: (pubs) => {
|
||||||
|
for (const pub of pubs) {
|
||||||
|
if(pub.entryTags.howpublished && !(isValidUrl(pub.entryTags.howpublished))){
|
||||||
|
pub.entryTags.howpublished = "/pubs/" + pub.entryTags.howpublished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pubs.sort((a,b) => (a.citationKey > b.citationKey) ? -1 : ((b.citationKey > a.citationKey) ? 1 : 0))
|
||||||
|
/*
|
||||||
|
return pubs.sort((a,b) => {
|
||||||
|
let aPrime = 5000
|
||||||
|
let bPrime = 5000
|
||||||
|
if(a.entryTags.year === 'forthcoming'){aPrime = 5000} else {aPrime = a.entryTags.year}
|
||||||
|
if(b.entryTags.year === 'forthcoming'){bPrime = 5000} else {bPrime = b.entryTags.year}
|
||||||
|
return bPrime - aPrime
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: album_art } = await useFetch('https://unboundedpress.org/api/album_art.files?pagesize=200')
|
||||||
|
|
||||||
|
const { data: releases } = await useFetch('https://unboundedpress.org/api/releases?pagesize=200', {
|
||||||
|
//lazy: true,
|
||||||
|
//server: false,
|
||||||
|
transform: (releases) => {
|
||||||
|
for (const release of releases) {
|
||||||
|
release.album_art_id = album_art.value.find(obj => {return obj.filename === release.album_art})._id.$oid
|
||||||
|
}
|
||||||
|
return releases.sort((a,b) => b.date - a.date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
watch(releases, (response)=>{
|
||||||
|
//console.log(response)
|
||||||
|
for (const item of response) {
|
||||||
|
useFetch(`https://unboundedpress.org/api/album_art.files?filter={"filename":"${item.album_art}"}`).then((response) => {
|
||||||
|
item.album_art_id = response.data.value[0]._id.$oid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
}, {
|
||||||
|
//deep: true,
|
||||||
|
immediate: true
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Home / Works - Pieces, Publications, and Albums'
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 7.2 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 367 KiB |
After Width: | Height: | Size: 39 KiB |
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 4.4 KiB |
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 266 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 2.2 MiB |
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
import {defineStore} from "pinia";
|
||||||
|
|
||||||
|
export const useAudioPlayerStore = defineStore("AudioPlayerStore", {
|
||||||
|
state: () => ({"soundcloud_trackid": "1032587794"}),
|
||||||
|
actions: {
|
||||||
|
setSoundCloudTrackID(trackid) {
|
||||||
|
if (typeof trackid !== 'undefined') {
|
||||||
|
this.soundcloud_trackid = trackid
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearSoundCloudTrackID() {
|
||||||
|
this.soundcloud_trackid = 'undefined'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1,15 @@
|
|||||||
|
import {defineStore} from "pinia";
|
||||||
|
|
||||||
|
export const useModalStore = defineStore("ModalStore", {
|
||||||
|
state: () => ({"type": "", "aspect":"", "isOpen":false, "bucket":"", "gallery":"", "vimeo_trackid":""}),
|
||||||
|
actions: {
|
||||||
|
setModalProps(type, aspect, isOpen, bucket, gallery, vimeo_trackid) {
|
||||||
|
this.type = type
|
||||||
|
this.aspect = aspect
|
||||||
|
this.isOpen = isOpen
|
||||||
|
this.bucket = bucket
|
||||||
|
this.gallery = gallery
|
||||||
|
this.vimeo_trackid = vimeo_trackid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|