Merge redesign into main
This commit is contained in:
commit
1ca6cb8646
2
app.vue
2
app.vue
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-white min-w-[800px] min-h-[80vh]">
|
<div class="bg-white min-h-[80vh]">
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage/>
|
<NuxtPage/>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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 = {}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<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 text-black"
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
|
||||||
import { ref, toRefs, watch } from '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>
|
|
||||||
|
|
@ -2,13 +2,17 @@
|
||||||
<div class="inline-flex p-1 min-w-[25px]">
|
<div class="inline-flex p-1 min-w-[25px]">
|
||||||
<div v-show="visible" class="bg-black rounded-full text-xs inline-flex" >
|
<div v-show="visible" class="bg-black rounded-full text-xs inline-flex" >
|
||||||
|
|
||||||
<button v-if="type === 'score'" @click="modalStore.setModalProps('pdf', 'aspect-[1/1.414]', true, '', '', '', link, work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&auto_play=true&show_user=false' : '')" class="inline-flex p-1">
|
<!-- Score: Always opens modal -->
|
||||||
|
<button v-if="type === 'score'" @click="openWithHash('score')" class="inline-flex p-1">
|
||||||
<Icon name="ion:book-sharp" style="color: white" />
|
<Icon name="ion:book-sharp" style="color: white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a v-else-if="type === 'document'" :href="isExternalLink ? link : undefined" :target="isExternalLink ? '_blank' : undefined" :rel="isExternalLink ? 'noopener noreferrer' : undefined" @click="openDocument()" class="inline-flex p-1 cursor-pointer">
|
<!-- Document: Mobile = External opens new tab, PDF downloads, internal pages open modal -->
|
||||||
<Icon name="ion:book-sharp" style="color: white" />
|
<span v-if="type === 'document'">
|
||||||
</a>
|
<a :href="isExternalLink ? link : undefined" :target="isExternalLink ? '_blank' : undefined" :rel="isExternalLink ? 'noopener noreferrer' : undefined" @click="openDocument()" class="inline-flex p-1 cursor-pointer">
|
||||||
|
<Icon name="ion:book-sharp" style="color: white" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
<a v-else-if="type === 'buy'" :href="link" :target="newTab ? '_blank' : undefined" :rel="newTab ? 'noopener noreferrer' : undefined" class="inline-flex p-1 cursor-pointer">
|
<a v-else-if="type === 'buy'" :href="link" :target="newTab ? '_blank' : undefined" :rel="newTab ? 'noopener noreferrer' : undefined" class="inline-flex p-1 cursor-pointer">
|
||||||
<Icon name="bxs:purchase-tag" style="color: white" />
|
<Icon name="bxs:purchase-tag" style="color: white" />
|
||||||
|
|
@ -26,11 +30,11 @@
|
||||||
<Icon name="wpf:speaker" style="color: white" />
|
<Icon name="wpf:speaker" style="color: white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="modalStore.setModalProps('video', 'aspect-video', true, '', '', work.vimeo_trackid)" v-else-if="type === 'video'" class="inline-flex p-1">
|
<button @click="openWithHash('video')" v-else-if="type === 'video'" class="inline-flex p-1">
|
||||||
<Icon name="fluent:video-48-filled" style="color: white" />
|
<Icon name="fluent:video-48-filled" style="color: white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'images', work.gallery, '', '', work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&auto_play=true&show_user=false' : '')" v-else="type === 'image'" class="inline-flex p-1">
|
<button @click="openWithHash('image')" v-else-if="type === 'image'" class="inline-flex p-1">
|
||||||
<Icon name="mdi:camera" style="color: white" />
|
<Icon name="mdi:camera" style="color: white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -48,6 +52,13 @@
|
||||||
const audioPlayerStore = useAudioPlayerStore()
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
const modalStore = useModalStore()
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const slugify = (title) => {
|
||||||
|
if (!title) return ''
|
||||||
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const workSlug = computed(() => slugify(props.work?.title))
|
||||||
|
|
||||||
const isExternalLink = computed(() => {
|
const isExternalLink = computed(() => {
|
||||||
return props.link && !props.link.endsWith('.pdf') && !props.link.startsWith('/')
|
return props.link && !props.link.endsWith('.pdf') && !props.link.startsWith('/')
|
||||||
})
|
})
|
||||||
|
|
@ -63,4 +74,21 @@
|
||||||
modalStore.setModalProps('document', 'aspect-[1/1.414]', true, '', '', '', '', '', props.link)
|
modalStore.setModalProps('document', 'aspect-[1/1.414]', true, '', '', '', '', '', props.link)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openWithHash = (type) => {
|
||||||
|
const slug = workSlug.value
|
||||||
|
const hash = type + '|' + slug
|
||||||
|
|
||||||
|
if (type === 'score') {
|
||||||
|
modalStore.setModalProps('pdf', 'aspect-[1/1.414]', true, '', '', '', props.link, props.work?.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + props.work.soundcloud_trackid + '&auto_play=true&show_user=false' : '')
|
||||||
|
} else if (type === 'video') {
|
||||||
|
modalStore.setModalProps('video', 'aspect-video', true, '', '', props.work.vimeo_trackid)
|
||||||
|
} else if (type === 'image') {
|
||||||
|
modalStore.setModalProps('image', 'aspect-auto', true, 'images', props.work.gallery, '', '', props.work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + props.work.soundcloud_trackid + '&auto_play=true&show_user=false' : '', '', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug && typeof window !== 'undefined') {
|
||||||
|
window.history.replaceState({}, '', '/#' + hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
:loop="true"
|
:loop="true"
|
||||||
:spaceBetween="30"
|
:spaceBetween="30"
|
||||||
:centeredSlides="true"
|
:centeredSlides="true"
|
||||||
|
:initialSlide="initialIndex"
|
||||||
:autoplay="{
|
:autoplay="{
|
||||||
delay: 4000,
|
delay: 4000,
|
||||||
disableOnInteraction: false,
|
disableOnInteraction: false,
|
||||||
|
|
@ -33,6 +34,6 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: ['gallery', 'bucket']
|
props: ['gallery', 'bucket', 'initialIndex']
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
39
components/ImageViewer.vue
Normal file
39
components/ImageViewer.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full w-full">
|
||||||
|
<div class="flex-1 flex items-center justify-center min-h-0">
|
||||||
|
<NuxtImg
|
||||||
|
v-if="gallery[currentIndex]"
|
||||||
|
:src="'/' + bucket + '/' + gallery[currentIndex].image"
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dots indicator -->
|
||||||
|
<div v-if="gallery.length > 1" class="flex-none flex gap-2 py-2 justify-center">
|
||||||
|
<button
|
||||||
|
v-for="(img, index) in gallery"
|
||||||
|
:key="index"
|
||||||
|
@click="currentIndex = index"
|
||||||
|
class="w-2 h-2 rounded-full transition-colors"
|
||||||
|
:class="index === currentIndex ? 'bg-gray-600' : 'bg-gray-300'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
bucket: String,
|
||||||
|
gallery: Array,
|
||||||
|
initialIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentIndex = ref(props.initialIndex)
|
||||||
|
|
||||||
|
watch(() => props.initialIndex, (newVal) => {
|
||||||
|
currentIndex.value = newVal
|
||||||
|
})
|
||||||
|
</script>
|
||||||
272
components/IndexContent.vue
Normal file
272
components/IndexContent.vue
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-5 gap-8">
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<section class="mb-12">
|
||||||
|
<h2 class="text-xl mb-6 border-b border-gray-200 pb-2">pieces</h2>
|
||||||
|
|
||||||
|
<div v-for="item in works" :key="item.year" class="mb-6">
|
||||||
|
<p class="text-sm text-gray-500 mb-2">{{ item.year }}</p>
|
||||||
|
<div v-for="work in item.works" :key="work.title" class="mb-3">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="hasItems(work)"
|
||||||
|
:to="'/works/' + slugify(work.title)"
|
||||||
|
class="italic hover:underline"
|
||||||
|
>
|
||||||
|
<span v-html="work.title"></span>
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="italic">
|
||||||
|
<span v-html="work.title"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<IconButton v-if="work.score" type="score" :work="work" :link="work.score"></IconButton>
|
||||||
|
<IconButton v-if="work.soundcloud_trackid" type="audio" :work="work"></IconButton>
|
||||||
|
<IconButton v-if="work.vimeo_trackid" type="video" :work="work"></IconButton>
|
||||||
|
<IconButton v-if="work.gallery" type="image" :work="work"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl mb-6 border-b border-gray-200 pb-2">writings</h2>
|
||||||
|
|
||||||
|
<div v-for="item in pubs" :key="item.citationKey" class="mb-4">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div>
|
||||||
|
<span v-html="item.entryTags.title" class="italic"></span>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ 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>
|
||||||
|
<IconButton
|
||||||
|
v-if="item.entryTags.howpublished"
|
||||||
|
type="document"
|
||||||
|
:link="item.entryTags.howpublished"
|
||||||
|
></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl mb-6 border-b border-gray-200 pb-2">albums</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-1 gap-6">
|
||||||
|
<div v-for="item in releases" :key="item.title" class="text-center">
|
||||||
|
<p class="italic mb-2">{{ item.title }}</p>
|
||||||
|
<button @click="openAlbumModal(item)" class="block mx-auto">
|
||||||
|
<nuxt-img
|
||||||
|
:src="'/album_art/' + item.album_art"
|
||||||
|
quality="50"
|
||||||
|
class="w-32 sm:w-48"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div class="flex justify-center gap-2 mt-2">
|
||||||
|
<IconButton
|
||||||
|
v-if="item.discogs_id"
|
||||||
|
type="discogs"
|
||||||
|
:link="'https://www.discogs.com/release/' + item.discogs_id"
|
||||||
|
:newTab="true"
|
||||||
|
></IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-if="item.buy_link"
|
||||||
|
type="buy"
|
||||||
|
:link="item.buy_link"
|
||||||
|
:newTab="true"
|
||||||
|
></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
import { watchEffect, onMounted } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps(['slug'])
|
||||||
|
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const slugify = (title) => {
|
||||||
|
if (!title) return ''
|
||||||
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});
|
||||||
|
|
||||||
|
const hasItems = (work) => {
|
||||||
|
return work.vimeo_trackid || (work.images && work.images.length) || work.score
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidUrl = urlString => {
|
||||||
|
var pattern = /^((http|https|ftp):\/\/)/;
|
||||||
|
return pattern.test(urlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: works } = await useFetch('/api/works', {
|
||||||
|
key: 'works-indexcontent',
|
||||||
|
transform: (works) => {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(works))
|
||||||
|
for (const work of cloned) {
|
||||||
|
if(work.score){
|
||||||
|
work.score = "/scores/" + work.score
|
||||||
|
}
|
||||||
|
if(work.images){
|
||||||
|
let gallery = [];
|
||||||
|
for (const image of work.images){
|
||||||
|
gallery.push({
|
||||||
|
image: image.filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
work.gallery = gallery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let priorityGroups = groupBy(cloned, work => work.priority)
|
||||||
|
let groups = groupBy(priorityGroups["1"], work => new Date(work.date).getFullYear())
|
||||||
|
groups = Object.keys(groups).map((year) => {
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
works: groups[year].sort((a,b) => new Date(b.date) - new Date(a.date))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
groups.sort((a,b) => b.year - a.year)
|
||||||
|
if (priorityGroups["2"]) {
|
||||||
|
groups.push({year: "miscellany", works: priorityGroups["2"].sort((a,b) => new Date(b.date) - new Date(a.date))})
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: pubs } = await useFetch('/api/publications', {
|
||||||
|
key: 'publications-indexcontent',
|
||||||
|
transform: (pubs) => {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(pubs))
|
||||||
|
for (const pub of cloned) {
|
||||||
|
if(pub.entryTags && pub.entryTags.howpublished && !(isValidUrl(pub.entryTags.howpublished))){
|
||||||
|
pub.entryTags.howpublished = "/pubs/" + pub.entryTags.howpublished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned.sort((a, b) => {
|
||||||
|
const getYear = (key) => parseInt(key.replace(/\D/g, '')) || 0
|
||||||
|
const getSuffix = (key) => key.replace(/^Winter\d+/, '') || ''
|
||||||
|
|
||||||
|
const yearA = getYear(a.citationKey)
|
||||||
|
const yearB = getYear(b.citationKey)
|
||||||
|
|
||||||
|
if (yearA !== yearB) return yearB - yearA
|
||||||
|
|
||||||
|
return getSuffix(b.citationKey).localeCompare(getSuffix(a.citationKey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: releases } = await useFetch('/api/releases', {
|
||||||
|
key: 'releases-indexcontent',
|
||||||
|
transform: (releases) => {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(releases))
|
||||||
|
return cloned.sort((a,b) => {
|
||||||
|
const dateA = parseInt(a.date) || 0
|
||||||
|
const dateB = parseInt(b.date) || 0
|
||||||
|
return dateB - dateA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const findWorkBySlug = (slugToFind) => {
|
||||||
|
if (!works.value) return null
|
||||||
|
for (const group of works.value) {
|
||||||
|
const found = group.works.find(w => slugify(w.title) === slugToFind)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAlbumModal = (album) => {
|
||||||
|
modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: album.album_art}], '', '', '', '', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openModalFromHash = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const hash = window.location.hash.slice(1)
|
||||||
|
if (!hash) return
|
||||||
|
|
||||||
|
const [hashType, hashSlug] = hash.includes('|') ? hash.split('|') : [null, null]
|
||||||
|
if (!hashType || !hashSlug) return
|
||||||
|
|
||||||
|
const work = findWorkBySlug(hashSlug)
|
||||||
|
if (!work) return
|
||||||
|
|
||||||
|
if (hashType === 'score' && work.score) {
|
||||||
|
modalStore.setModalProps(
|
||||||
|
'pdf',
|
||||||
|
'aspect-[1/1.414]',
|
||||||
|
true,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
work.score,
|
||||||
|
work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&auto_play=true&show_user=false' : ''
|
||||||
|
)
|
||||||
|
} else if (hashType === 'video' && work.vimeo_trackid) {
|
||||||
|
modalStore.setModalProps('video', 'aspect-video', true, '', '', work.vimeo_trackid)
|
||||||
|
} else if (hashType === 'image' && work.gallery) {
|
||||||
|
modalStore.setModalProps(
|
||||||
|
'image',
|
||||||
|
'aspect-auto',
|
||||||
|
true,
|
||||||
|
'images',
|
||||||
|
work.gallery,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&auto_play=true&show_user=false' : '',
|
||||||
|
'',
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.slug && works.value) {
|
||||||
|
const work = findWorkBySlug(props.slug)
|
||||||
|
if (work?.score) {
|
||||||
|
modalStore.setModalProps(
|
||||||
|
'pdf',
|
||||||
|
'aspect-[1/1.414]',
|
||||||
|
true,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
work.score,
|
||||||
|
work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&auto_play=true&show_user=false' : ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!props.slug && typeof window !== 'undefined') {
|
||||||
|
openModalFromHash()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => modalStore.isOpen, (isOpen) => {
|
||||||
|
if (!isOpen && typeof window !== 'undefined' && window.location.hash) {
|
||||||
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
112
components/Menu.vue
Normal file
112
components/Menu.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
@click="isOpen = true"
|
||||||
|
class="flex-shrink-0 hover:bg-gray-100 rounded mt-2"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="slide">
|
||||||
|
<div v-if="isOpen" class="fixed inset-0 z-50">
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="isOpen = false"></div>
|
||||||
|
|
||||||
|
<div class="absolute left-0 top-0 h-full w-64 bg-white shadow-lg p-6 pt-16">
|
||||||
|
<nav class="space-y-4">
|
||||||
|
<NuxtLink to="/" class="block text-xl hover:underline" @click="isOpen = false">
|
||||||
|
michael winter
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400 uppercase tracking-wider mb-2">Works</p>
|
||||||
|
<div class="space-y-2 ml-2">
|
||||||
|
<NuxtLink
|
||||||
|
to="/pieces"
|
||||||
|
class="block hover:underline"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
pieces
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/writings"
|
||||||
|
class="block hover:underline"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
writings
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/albums"
|
||||||
|
class="block hover:underline"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
albums
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400 uppercase tracking-wider mb-2">Events</p>
|
||||||
|
<div class="space-y-2 ml-2">
|
||||||
|
<NuxtLink
|
||||||
|
to="/performances"
|
||||||
|
class="block hover:underline"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
performances
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/lectures"
|
||||||
|
class="block hover:underline"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
lectures
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/about"
|
||||||
|
class="block hover:underline"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
about
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://unboundedpress.org/code"
|
||||||
|
target="_blank"
|
||||||
|
class="block hover:underline"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
code
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
isOpen.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="fixed inset-0 bg-black/50 z-15 transition duration-300" />
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
<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
|
|
||||||
maxHeight?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
modelValue: false,
|
|
||||||
persistent: false,
|
|
||||||
fullscreen: false,
|
|
||||||
maxHeight: '85vh',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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-[min(85vw,1200px)] rounded-lg': !fullscreen,
|
|
||||||
}"
|
|
||||||
:style="!fullscreen ? { maxHeight } : {}"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</TransitionRoot>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
// import { ref } from 'vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="px-4 py-3">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<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>
|
|
||||||
35
components/SimpleModal.vue
Normal file
35
components/SimpleModal.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="modelValue" class="fixed inset-0 z-50">
|
||||||
|
<!-- Backdrop - click to close -->
|
||||||
|
<div class="fixed inset-0 bg-black/50 cursor-pointer" @click="close"></div>
|
||||||
|
|
||||||
|
<!-- Modal Panel -->
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-xl w-[80vw] aspect-video max-h-[85vh] overflow-hidden relative pointer-events-auto"
|
||||||
|
:style="{ maxHeight }"
|
||||||
|
>
|
||||||
|
<!-- Content -->
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: Boolean,
|
||||||
|
maxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: '85vh'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -1,86 +1,175 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-[63%,35%] w-full font-thin sticky top-0 bg-white p-2 z-20">
|
<div class="min-h-screen bg-white">
|
||||||
<div>
|
<!-- Mobile header -->
|
||||||
<div class="text-5xl p-2"> <NuxtLink to='/'>michael winter</NuxtLink></div>
|
<header class="sticky top-0 bg-white z-40 border-b border-gray-200 lg:hidden">
|
||||||
<div class="inline-flex text-2xl ml-4">
|
<div class="max-w-7xl mx-auto px-4 py-2 flex items-center justify-between gap-4">
|
||||||
<NuxtLink class="px-3" to='/'>works</NuxtLink>
|
<Menu />
|
||||||
<NuxtLink class="px-3" to='/events'>events</NuxtLink>
|
<h1 class="text-lg md:text-2xl whitespace-nowrap">
|
||||||
<NuxtLink class="px-3" to='/about'>about</NuxtLink>
|
<NuxtLink to='/' class="hover:underline">michael winter</NuxtLink>
|
||||||
<NuxtLink class="px-3" to='https://unboundedpress.org/code'>code</NuxtLink>
|
</h1>
|
||||||
</div>
|
<div class="flex-1 text-right text-lg md:text-xl">{{ pageTitle }}</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>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- TODO: this needs to be automatically flipped off when there are no upcoming events-->
|
<!-- Desktop header -->
|
||||||
<!------
|
<header class="sticky top-0 bg-white z-40 border-b border-gray-200 hidden lg:block">
|
||||||
<div class="px-1 bg-zinc-100 rounded-lg text-center">
|
<div class="w-full px-4 py-2 flex items-center justify-between">
|
||||||
<div class="text-sm">upcoming events</div>
|
<h1 class="text-lg md:text-2xl whitespace-nowrap">
|
||||||
<EventSlider :upcoming_events="upcoming_events" class="max-w-[95%] min-h-[80%]"></EventSlider>
|
<NuxtLink to='/' class="hover:underline">michael winter</NuxtLink>
|
||||||
|
</h1>
|
||||||
|
<div class="text-lg md:text-xl">{{ pageTitle }}</div>
|
||||||
</div>
|
</div>
|
||||||
-->
|
</header>
|
||||||
|
|
||||||
</div>
|
<div class="flex">
|
||||||
<slot /> <!-- required here only -->
|
<!-- Sidebar for wide screens -->
|
||||||
<div class="fixed bottom-0 bg-white p-2 w-full flex justify-center z-20">
|
<aside class="hidden lg:block w-48 flex-shrink-0 border-r border-gray-200 p-4 pt-12 sticky self-start top-12 z-20">
|
||||||
<ClientOnly>
|
<nav class="space-y-4 ml-4">
|
||||||
<iframe width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay" v-if="audioPlayerStore.soundcloud_trackid !== 'undefined'"
|
<div>
|
||||||
: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>
|
<p class="text-xs text-gray-400 uppercase tracking-wider mb-2">Works</p>
|
||||||
</ClientOnly>
|
<div class="space-y-2 ml-2">
|
||||||
</div>
|
<NuxtLink to="/pieces" class="block hover:underline">pieces</NuxtLink>
|
||||||
|
<NuxtLink to="/writings" class="block hover:underline">writings</NuxtLink>
|
||||||
|
<NuxtLink to="/albums" class="block hover:underline">albums</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal v-model="modalStore.isOpen" :maxHeight="modalStore.type === 'image' && modalStore.soundcloudUrl ? 'calc(85vh + 60px)' : '85vh'">
|
<div>
|
||||||
<ModalBody :class="modalStore.aspect">
|
<p class="text-xs text-gray-400 uppercase tracking-wider mb-2">Events</p>
|
||||||
<ImageSlider v-if="modalStore.type === 'image'" :bucket="modalStore.bucket" :gallery="modalStore.gallery"></ImageSlider>
|
<div class="space-y-2 ml-2">
|
||||||
|
<NuxtLink to="/performances" class="block hover:underline">performances</NuxtLink>
|
||||||
|
<NuxtLink to="/lectures" class="block hover:underline">lectures</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtLink to="/about" class="block hover:underline">about</NuxtLink>
|
||||||
|
<a href="https://unboundedpress.org/code" target="_blank" class="block hover:underline">code</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="fixed bottom-0 bg-white border-t border-gray-200 p-2 w-full flex justify-center z-30">
|
||||||
|
<ClientOnly>
|
||||||
|
<iframe
|
||||||
|
v-if="audioPlayerStore.soundcloud_trackid !== 'undefined'"
|
||||||
|
class="w-64"
|
||||||
|
height="20px"
|
||||||
|
scrolling="no"
|
||||||
|
frameborder="no"
|
||||||
|
: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>
|
||||||
|
</ClientOnly>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<SimpleModal v-model="modalStore.isOpen" :maxHeight="modalStore.type === 'image' && modalStore.soundcloudUrl ? 'calc(85vh + 60px)' : '85vh'">
|
||||||
|
<div class="flex flex-col h-full overflow-hidden relative p-4">
|
||||||
|
<!-- Navigation buttons for images - fixed to modal -->
|
||||||
|
<template v-if="modalStore.type === 'image' && modalStore.gallery && modalStore.gallery.length > 1">
|
||||||
|
<button @click="prevImage" class="absolute left-2 top-1/2 -translate-y-1/2 z-20 text-gray-600 hover:text-gray-900">
|
||||||
|
<Icon name="mdi:chevron-left" class="w-10 h-10" />
|
||||||
|
</button>
|
||||||
|
<button @click="nextImage" class="absolute right-2 top-1/2 -translate-y-1/2 z-20 text-gray-600 hover:text-gray-900">
|
||||||
|
<Icon name="mdi:chevron-right" class="w-10 h-10" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="modalStore.type === 'image'" class="flex-1 flex items-center justify-center min-h-0">
|
||||||
|
<ImageViewer :bucket="modalStore.bucket" :gallery="modalStore.gallery" :initialIndex="modalStore.initialIndex"></ImageViewer>
|
||||||
|
</div>
|
||||||
<div v-if="modalStore.type === 'image' && modalStore.soundcloudUrl" class="flex justify-center mt-2">
|
<div v-if="modalStore.type === 'image' && modalStore.soundcloudUrl" class="flex justify-center mt-2">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<iframe :src="modalStore.soundcloudUrl" width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay"></iframe>
|
<iframe :src="modalStore.soundcloudUrl" class="w-64" height="20px" scrolling="no" frameborder="no" allow="autoplay"></iframe>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="modalStore.type === 'video'" :class="modalStore.aspect" class="w-full h-full flex items-center justify-center p-4">
|
<div v-if="modalStore.type === 'video'" class="w-full h-full flex items-center justify-center">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<iframe :src="'https://player.vimeo.com/video/' + modalStore.vimeo_trackid" width="100%" height="100%" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen class="max-w-full max-h-full"></iframe>
|
<iframe
|
||||||
|
:src="'https://player.vimeo.com/video/' + modalStore.vimeo_trackid"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
frameborder="0"
|
||||||
|
webkitallowfullscreen
|
||||||
|
mozallowfullscreen
|
||||||
|
allowfullscreen
|
||||||
|
class="w-full h-full block"
|
||||||
|
></iframe>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="modalStore.type === 'document'" class="w-full flex flex-col" style="height: calc(85vh - 4rem)">
|
<div v-if="modalStore.type === 'document'" class="w-full flex flex-col h-[70vh]">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<iframe :src="modalStore.iframeUrl" width="100%" height="100%" frameborder="0" class="flex-grow"></iframe>
|
<iframe :src="modalStore.iframeUrl" width="100%" height="100%" frameborder="0" class="flex-grow"></iframe>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="modalStore.type === 'pdf'" class="flex flex-col h-full">
|
<div v-if="modalStore.type === 'pdf'" class="w-full h-full flex flex-col">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<iframe :src="modalStore.pdfUrl + '#toolbar=1&navpanes=0&sidebar=0'" width="100%" height="100%" frameborder="0" :class="[modalStore.soundcloudUrl ? 'max-h-[calc(85vh-60px)]' : 'max-h-[calc(85vh-2rem)]', 'flex-grow']"></iframe>
|
<iframe :src="modalStore.pdfUrl + '#toolbar=1&navpanes=0&sidebar=0'" width="100%" height="100%" frameborder="0" class="flex-1"></iframe>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<div v-if="modalStore.soundcloudUrl" class="flex justify-center mt-2">
|
<div v-if="modalStore.soundcloudUrl" class="flex justify-center mt-2">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<iframe :src="modalStore.soundcloudUrl" width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay"></iframe>
|
<iframe :src="modalStore.soundcloudUrl" class="w-64" height="20px" scrolling="no" frameborder="no" allow="autoplay"></iframe>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="modalStore.type === 'pdf' || modalStore.type === 'image' || modalStore.type === 'document'" class="absolute bottom-2 right-2 z-10">
|
<div v-if="modalStore.type === 'pdf' || modalStore.type === 'image' || modalStore.type === 'document'" class="absolute bottom-4 right-4 z-10">
|
||||||
<a :href="modalStore.type === 'pdf' ? modalStore.pdfUrl : modalStore.type === 'image' ? '/' + modalStore.bucket + '/' + modalStore.gallery[0]?.image : modalStore.type === 'document' ? modalStore.iframeUrl : undefined" target="_blank" rel="noopener noreferrer" class="p-2 bg-gray-600 rounded-lg inline-flex items-center justify-center pointer-events-auto">
|
<a :href="modalStore.type === 'pdf' ? modalStore.pdfUrl : modalStore.type === 'image' ? '/' + modalStore.bucket + '/' + modalStore.gallery[0]?.image : modalStore.type === 'document' ? modalStore.iframeUrl : undefined" target="_blank" rel="noopener noreferrer" class="p-2 bg-gray-600 rounded-lg inline-flex items-center justify-center pointer-events-auto">
|
||||||
<Icon name="mdi:open-in-new" class="w-5 h-5 text-white" />
|
<Icon name="mdi:open-in-new" class="w-5 h-5 text-white" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</div>
|
||||||
</Modal>
|
</SimpleModal>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
import { useModalStore } from "@/stores/ModalStore"
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
import { onMounted } from "vue"
|
import { onMounted, computed, ref } from "vue"
|
||||||
|
|
||||||
const audioPlayerStore = useAudioPlayerStore()
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
const modalStore = useModalStore()
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const currentImageIndex = ref(0)
|
||||||
|
|
||||||
|
const prevImage = () => {
|
||||||
|
if (modalStore.gallery && modalStore.gallery.length > 0) {
|
||||||
|
currentImageIndex.value = (currentImageIndex.value - 1 + modalStore.gallery.length) % modalStore.gallery.length
|
||||||
|
modalStore.initialIndex = currentImageIndex.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextImage = () => {
|
||||||
|
if (modalStore.gallery && modalStore.gallery.length > 0) {
|
||||||
|
currentImageIndex.value = (currentImageIndex.value + 1) % modalStore.gallery.length
|
||||||
|
modalStore.initialIndex = currentImageIndex.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => modalStore.isOpen, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
currentImageIndex.value = modalStore.initialIndex || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
if (path === '/' || path === '/pieces') return 'pieces'
|
||||||
|
if (path === '/writings') return 'writings'
|
||||||
|
if (path === '/albums') return 'albums'
|
||||||
|
if (path === '/performances') return 'performances'
|
||||||
|
if (path === '/lectures') return 'lectures'
|
||||||
|
if (path === '/about') return 'about'
|
||||||
|
if (path.startsWith('/works/')) return 'works'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if(route.params.files == 'scores') {
|
if(route.params.files == 'scores') {
|
||||||
useFetch('/api/works', {
|
useFetch('/api/works', {
|
||||||
|
|
@ -96,17 +185,4 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: upcoming_events } = await useFetch('/api/events', {
|
|
||||||
transform: (events) => {
|
|
||||||
const now = new Date().getTime()
|
|
||||||
const upcoming = events.filter(e => new Date(e.start_date).getTime() >= now)
|
|
||||||
for (const event of upcoming) {
|
|
||||||
let date = new Date(event.start_date)
|
|
||||||
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
|
||||||
}
|
|
||||||
return upcoming.sort((a,b) => new Date(a.start_date) - new Date(b.start_date))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export default defineNuxtConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
routeRules: {
|
routeRules: {
|
||||||
|
'/': { redirect: '/pieces' },
|
||||||
'/hdp': { redirect: '/a_history_of_the_domino_problem' },
|
'/hdp': { redirect: '/a_history_of_the_domino_problem' },
|
||||||
|
|
||||||
// Default: prerender all routes (static HTML)
|
// Default: prerender all routes (static HTML)
|
||||||
|
|
@ -32,6 +33,6 @@ export default defineNuxtConfig({
|
||||||
prerender: { crawlLinks: true}
|
prerender: { crawlLinks: true}
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
payloadExtraction: true
|
payloadExtraction: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
159
pages/about.vue
159
pages/about.vue
|
|
@ -1,91 +1,98 @@
|
||||||
<template>
|
<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="grid grid-cols-1 lg:grid-cols-5 gap-8">
|
||||||
<div class="px-5">
|
<div class="lg:col-span-3">
|
||||||
<p class="text-lg">about</p>
|
<section>
|
||||||
|
<div class="mb-8">
|
||||||
|
<p class="mb-4">
|
||||||
|
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 epistemology, mathematics, algorithmic information theory, and the history of science. Phenomenologically, I contemplate the possibility that everything is potentially computable, even our experiences. 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 understanding 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
My music and installations have 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 that presented over 350 events in 8 years. From 2018 to 2019, I was a fellow / artist-in-residence at the Akademie Schloss Solitude in Stuttgart, Germany. I currently teach as University Professor of Sound and Intermedia at the Gustav Mahler Privatuniversität für Musik in Klagenfurt, Austria while maintaining my primary residence in Berlin, Germany.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="leading-tight py-2 ml-3 text-sm">
|
<div class="mb-8 space-y-2">
|
||||||
<div class="leading-tight py-2">
|
<a href="mailto:mwinter@unboundedpress.org" class="block hover:underline text-gray-500">contact</a>
|
||||||
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 epistemology, mathematics, algorithmic information theory, and the history of science. Phenomenologically, I contemplate the possibility that everything is potentially computable, even our experiences. 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 understanding 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.
|
<a href="/cv" class="block hover:underline text-gray-500">cv</a>
|
||||||
</div>
|
<a href="/works_list" class="block hover:underline text-gray-500">works list</a>
|
||||||
<div class="leading-tight py-2">
|
</div>
|
||||||
My music and installations have 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 that presented over 350 events in 8 years. From 2018 to 2019, I was a fellow / artist-in-residence at the Akademie Schloss Solitude in Stuttgart, Germany. I currently teach as University Professor of Sound and Intermedia at the Gustav Mahler Privatuniversität für Musik in Klagenfurt, Austria while maintaining my primary residence in Berlin, Germany.
|
|
||||||
</div>
|
<div id="mc_embed_signup">
|
||||||
<br>
|
<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">
|
||||||
<br>
|
<label for="mce-EMAIL" class="block mb-2">subscribe to my mailing list to know about upcoming events</label>
|
||||||
<div id="mc_embed_signup">
|
<div class="flex gap-2">
|
||||||
<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">
|
<input id="mce-EMAIL" type="email" value="" name="EMAIL" placeholder="email address" required="" class="flex-1 px-3 py-2 border border-gray-300 rounded">
|
||||||
<label for="mce-EMAIL">subscribe to my mailing list to know about upcoming events</label>
|
<div style="position: absolute; left: -5000px;" aria-hidden="true">
|
||||||
<input id="mce-EMAIL" type="email" value="" name="EMAIL" placeholder="email address" required="" class="email">
|
<input type="text" name="b_bdadd25738fedf704641f3a80_01c5761ebb" tabindex="-1" value="">
|
||||||
<div style="position: absolute; left: -5000px;" aria-hidden="true">
|
</div>
|
||||||
<input type="text" name="b_bdadd25738fedf704641f3a80_01c5761ebb" tabindex="-1" value="">
|
<input id="mc-embedded-subscribe" type="submit" value="subscribe" name="subscribe" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">
|
||||||
</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" class="mt-[-6px]"></IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<div class="inline-flex place-items-center p-2">
|
|
||||||
CV
|
|
||||||
<div>
|
|
||||||
<IconButton :visible="true" type="document" work="placeholder" link="/cv" class="mt-[-6px]"></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="/works_list" class="mt-[-6px]"></IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
</div>
|
</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>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5">
|
</section>
|
||||||
<ImageSlider bucket="images" :gallery="gallery" class="max-w-[90%]"></ImageSlider>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<section v-if="gallery?.length" class="lg:sticky lg:top-24">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
v-for="(image, index) in gallery"
|
||||||
|
:key="index"
|
||||||
|
@click="openImageModal(index)"
|
||||||
|
class="block w-full relative overflow-hidden group"
|
||||||
|
>
|
||||||
|
<nuxt-img
|
||||||
|
:src="'/images/' + image.image"
|
||||||
|
:alt="image.credit"
|
||||||
|
class="w-full aspect-[4/3] object-cover transition-opacity duration-200 group-hover:opacity-50"
|
||||||
|
/>
|
||||||
|
<p v-if="image.credit" class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 text-center text-xs px-2 bg-black/50 text-white">photo credit: {{ image.credit }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
const { data: gallery } = await useFetch('/api/my_image_gallery')
|
const { data: gallery } = await useFetch('/api/my_image_gallery')
|
||||||
|
|
||||||
|
const openImageModal = (index) => {
|
||||||
|
modalStore.setModalProps('image', 'aspect-auto', true, 'images', gallery.value, '', '', '', '', index)
|
||||||
|
}
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
titleTemplate: 'Michael Winter - About - Short Bio, Contact, CV, Works List, and Mailing List'
|
titleTemplate: 'Michael Winter - About'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
#mc_embed_signup form {text-align:left; padding:2px 0 2px 0;}
|
#mc_embed_signup input.email {
|
||||||
.mc-field-group { display: inline-block; } /* positions input field horizontally */
|
width: 100%;
|
||||||
#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;}
|
@media (min-width: 640px) {
|
||||||
#mc_embed_signup .clear {display: inline-block;} /* positions button horizontally in line with input */
|
#mc_embed_signup input.email {
|
||||||
#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;}
|
width: auto;
|
||||||
#mc_embed_signup .button:hover {background-color:#777; cursor:pointer;}
|
flex: 1;
|
||||||
#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 .button {
|
||||||
#mc_embed_signup #mce-success-response {color:#529214; display:none;}
|
width: auto;
|
||||||
#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) {
|
@media (max-width: 639px) {
|
||||||
#mc_embed_signup input.email {width:100%; margin-bottom:5px;}
|
#mc_embed_signup form > div {
|
||||||
#mc_embed_signup .clear {display: block; width: 100% }
|
flex-direction: column;
|
||||||
#mc_embed_signup .button {width: 100%; margin:0; }
|
}
|
||||||
}
|
#mc_embed_signup .button {
|
||||||
#mc_embed_signup{clear:left; width:100%;}
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
62
pages/albums.vue
Normal file
62
pages/albums.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
<div v-for="item in releases" :key="item.title" class="text-center group">
|
||||||
|
<button @click="openAlbumModal(item)" class="block w-full relative overflow-hidden">
|
||||||
|
<nuxt-img
|
||||||
|
:src="'/album_art/' + item.album_art"
|
||||||
|
quality="50"
|
||||||
|
class="w-full aspect-[2/1] object-contain border border-gray-300 transition-opacity duration-200 group-hover:opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 text-center italic px-2">{{ item.title }}</p>
|
||||||
|
</button>
|
||||||
|
<div class="flex justify-center gap-4 mt-2 text-sm text-gray-500">
|
||||||
|
<a
|
||||||
|
v-if="item.discogs_id"
|
||||||
|
:href="'https://www.discogs.com/release/' + item.discogs_id"
|
||||||
|
target="_blank"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
discogs
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="item.buy_link"
|
||||||
|
:href="item.buy_link"
|
||||||
|
target="_blank"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
buy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const { data: releases } = await useFetch('/api/releases', {
|
||||||
|
key: 'releases-page',
|
||||||
|
transform: (releases) => {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(releases))
|
||||||
|
return cloned.sort((a,b) => {
|
||||||
|
const dateA = parseInt(a.date) || 0
|
||||||
|
const dateB = parseInt(b.date) || 0
|
||||||
|
return dateB - dateA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const openAlbumModal = (album) => {
|
||||||
|
modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: album.album_art}], '', '', '', '', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Albums'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
111
pages/events.vue
111
pages/events.vue
|
|
@ -1,111 +0,0 @@
|
||||||
<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-if="performer.instrument_tags?.length"> - </span>
|
|
||||||
<span v-for="(instrument, index) in performer.instrument_tags">
|
|
||||||
<span v-if="index !== 0">, </span>
|
|
||||||
{{ instrument }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-5">
|
|
||||||
<p class="text-lg">lectures</p>
|
|
||||||
|
|
||||||
<div v-for="yearGroup in lecturesByYear" :key="yearGroup.year">
|
|
||||||
<p class="text-sm font-semibold mt-4 text-[#7F7F7F]">{{ yearGroup.year }}</p>
|
|
||||||
<div class="leading-tight py-2 ml-3 text-sm" v-for="item in yearGroup.talks">
|
|
||||||
<div class="gap-1">
|
|
||||||
<div>
|
|
||||||
{{item.location}}
|
|
||||||
<div v-for="talk in item.talks" class="ml-4 text-[#7F7F7F]">
|
|
||||||
{{ talk.title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const { data: events } = await useFetch('/api/events', {
|
|
||||||
transform: (events) => {
|
|
||||||
for (const event of events) {
|
|
||||||
let date = new Date(event.start_date)
|
|
||||||
event.formatted_date = ("0" + date.getDate()).slice(-2) + "." + ("0" + (date.getMonth() + 1)).slice(-2) + "." + date.getFullYear()
|
|
||||||
}
|
|
||||||
return events.sort((a,b) => new Date(b.start_date) - new Date(a.start_date))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: talksData } = await useFetch('/api/talks', {
|
|
||||||
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) + "." + 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) => new Date(b.date) - new Date(a.date))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const lecturesByYear = computed(() => {
|
|
||||||
if (!talksData.value) return []
|
|
||||||
|
|
||||||
const byYear = {}
|
|
||||||
for (const talk of talksData.value) {
|
|
||||||
const year = talk.date ? talk.date.getFullYear() : 'Unknown'
|
|
||||||
if (!byYear[year]) byYear[year] = []
|
|
||||||
byYear[year].push(talk)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(byYear)
|
|
||||||
.sort((a, b) => b - a)
|
|
||||||
.map(year => {
|
|
||||||
const talks = byYear[year].sort((a, b) => new Date(b.date) - new Date(a.date))
|
|
||||||
return { year, talks }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
titleTemplate: 'Michael Winter - Events - Performances and Lectures'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
164
pages/index.vue
164
pages/index.vue
|
|
@ -1,167 +1,7 @@
|
||||||
<template>
|
<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></div>
|
||||||
|
|
||||||
<div class="px-5">
|
|
||||||
<p class="text-lg">pieces</p>
|
|
||||||
|
|
||||||
<div class="py-2 ml-3" v-for="item in works">
|
|
||||||
<p class="text-sm font-semibold mt-4 text-[#7F7F7F]">{{ 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 items-start">
|
|
||||||
<span v-html="work.title" class="italic text-sm"></span>
|
|
||||||
<div class="inline-flex mt-[-4px]">
|
|
||||||
|
|
||||||
<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 items-start">
|
|
||||||
<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 mt-[-6px]"></IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-5">
|
|
||||||
<p class="text-lg">albums</p>
|
|
||||||
<div class="flex flex-col items-center leading-tight py-4 text-sm" v-for="item in releases">
|
|
||||||
<p class="leading-tight py-2">{{ item.title }}</p>
|
|
||||||
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: item.album_art}], '')">
|
|
||||||
<nuxt-img :src="'/album_art/' + item.album_art"
|
|
||||||
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" :newTab="true"></IconButton>
|
|
||||||
<IconButton :visible="item.buy_link" type="buy" :link="item.buy_link" :newTab="true"></IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
navigateTo('/pieces')
|
||||||
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 pattern = /^((http|https|ftp):\/\/)/;
|
|
||||||
return pattern.test(urlString)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const { data: images } = await useFetch('/api/images', { key: 'images' })
|
|
||||||
|
|
||||||
const { data: works } = await useFetch('/api/works', {
|
|
||||||
key: 'works',
|
|
||||||
transform: (works) => {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(works))
|
|
||||||
for (const work of cloned) {
|
|
||||||
if(work.score){
|
|
||||||
work.score = "/scores/" + work.score
|
|
||||||
}
|
|
||||||
if(work.images){
|
|
||||||
let gallery = [];
|
|
||||||
for (const image of work.images){
|
|
||||||
gallery.push({
|
|
||||||
image: image.filename,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
work.gallery = gallery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let priorityGroups = groupBy(cloned, work => work.priority)
|
|
||||||
let groups = groupBy(priorityGroups["1"], work => new Date(work.date).getFullYear())
|
|
||||||
groups = Object.keys(groups).map((year) => {
|
|
||||||
return {
|
|
||||||
year,
|
|
||||||
works: groups[year].sort((a,b) => new Date(b.date) - new Date(a.date))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
groups.sort((a,b) => b.year - a.year)
|
|
||||||
if (priorityGroups["2"]) {
|
|
||||||
groups.push({year: "miscellany", works: priorityGroups["2"].sort((a,b) => new Date(b.date) - new Date(a.date))})
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: pubs } = await useFetch('/api/publications', {
|
|
||||||
key: 'publications',
|
|
||||||
transform: (pubs) => {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(pubs))
|
|
||||||
for (const pub of cloned) {
|
|
||||||
if(pub.entryTags && pub.entryTags.howpublished && !(isValidUrl(pub.entryTags.howpublished))){
|
|
||||||
pub.entryTags.howpublished = "/pubs/" + pub.entryTags.howpublished
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cloned.sort((a, b) => {
|
|
||||||
const getYear = (key) => parseInt(key.replace(/\D/g, '')) || 0
|
|
||||||
const getSuffix = (key) => key.replace(/^Winter\d+/, '') || ''
|
|
||||||
|
|
||||||
const yearA = getYear(a.citationKey)
|
|
||||||
const yearB = getYear(b.citationKey)
|
|
||||||
|
|
||||||
if (yearA !== yearB) return yearB - yearA
|
|
||||||
|
|
||||||
return getSuffix(b.citationKey).localeCompare(getSuffix(a.citationKey))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: releases } = await useFetch('/api/releases', {
|
|
||||||
key: 'releases',
|
|
||||||
transform: (releases) => {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(releases))
|
|
||||||
return cloned.sort((a,b) => {
|
|
||||||
const dateA = parseInt(a.date) || 0
|
|
||||||
const dateB = parseInt(b.date) || 0
|
|
||||||
return dateB - dateA
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
titleTemplate: 'Michael Winter - Home / Works - Pieces, Publications, and Albums'
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
60
pages/lectures.vue
Normal file
60
pages/lectures.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<div v-for="yearGroup in lecturesByYear" :key="yearGroup.year" class="mb-8">
|
||||||
|
<p class="text-sm text-gray-500 mb-4">{{ yearGroup.year }}</p>
|
||||||
|
|
||||||
|
<div v-for="item in yearGroup.talks" :key="item.date + item.title" class="mb-4">
|
||||||
|
<div class="text-sm">{{ item.location }}</div>
|
||||||
|
<div v-for="talk in item.talks" :key="talk.title" class="ml-4 text-sm text-gray-500">
|
||||||
|
{{ talk.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const { data: talksData } = await useFetch('/api/talks', {
|
||||||
|
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) + "." + 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) => new Date(b.date) - new Date(a.date))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const lecturesByYear = computed(() => {
|
||||||
|
if (!talksData.value) return []
|
||||||
|
|
||||||
|
const byYear = {}
|
||||||
|
for (const talk of talksData.value) {
|
||||||
|
const year = talk.date ? talk.date.getFullYear() : 'Unknown'
|
||||||
|
if (!byYear[year]) byYear[year] = []
|
||||||
|
byYear[year].push(talk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(byYear)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.map(year => {
|
||||||
|
const talks = byYear[year].sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
|
return { year, talks }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Lectures'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
74
pages/performances.vue
Normal file
74
pages/performances.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<div v-for="yearGroup in eventsByYear" :key="yearGroup.year" class="mb-8">
|
||||||
|
<p class="text-sm text-gray-500 mb-4">{{ yearGroup.year }}</p>
|
||||||
|
|
||||||
|
<div v-for="item in yearGroup.events" :key="item.start_date" class="mb-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="text-sm text-gray-400 min-w-[80px]">
|
||||||
|
{{ item.formatted_date }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div>
|
||||||
|
{{ item.venue.city }}, {{ item.venue.state }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 ml-4">
|
||||||
|
{{ item.venue.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-2">
|
||||||
|
<div v-for="performance in item.program" :key="performance.work" class="mb-2">
|
||||||
|
<div class="italic">{{ performance.work }}</div>
|
||||||
|
<div v-if="performance.ensemble" class="ml-4 text-sm text-gray-500">
|
||||||
|
{{ performance.ensemble }}
|
||||||
|
</div>
|
||||||
|
<div v-if="performance.performers?.length" class="ml-4 text-sm text-gray-500">
|
||||||
|
<span v-for="(performer, idx) in performance.performers" :key="performer.name">
|
||||||
|
<span v-if="idx > 0">, </span>
|
||||||
|
{{ performer.name }}<span v-if="performer.instrument_tags?.length"> - {{ performer.instrument_tags.join(', ') }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const { data: events } = await useFetch('/api/events', {
|
||||||
|
transform: (events) => {
|
||||||
|
for (const event of events) {
|
||||||
|
let date = new Date(event.start_date)
|
||||||
|
event.formatted_date = ("0" + date.getDate()).slice(-2) + "." + ("0" + (date.getMonth() + 1)).slice(-2) + "." + date.getFullYear()
|
||||||
|
}
|
||||||
|
return events.sort((a,b) => new Date(b.start_date) - new Date(a.start_date))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventsByYear = computed(() => {
|
||||||
|
if (!events.value) return []
|
||||||
|
|
||||||
|
const byYear = {}
|
||||||
|
for (const event of events.value) {
|
||||||
|
const year = new Date(event.start_date).getFullYear()
|
||||||
|
if (!byYear[year]) byYear[year] = []
|
||||||
|
byYear[year].push(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(byYear)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.map(year => ({
|
||||||
|
year,
|
||||||
|
events: byYear[year].sort((a, b) => new Date(b.start_date) - new Date(a.start_date))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Performances'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
141
pages/pieces.vue
Normal file
141
pages/pieces.vue
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-5 gap-8">
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<section>
|
||||||
|
<div v-for="item in works" :key="item.year" class="mb-8">
|
||||||
|
<p class="text-sm text-gray-500 mb-3">{{ item.year }}</p>
|
||||||
|
<div v-for="work in item.works" :key="work.title" class="mb-4 ml-4">
|
||||||
|
<div>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="hasItems(work)"
|
||||||
|
:to="'/works/' + slugify(work.title)"
|
||||||
|
class="text-base italic hover:underline"
|
||||||
|
>
|
||||||
|
<span v-html="work.title"></span>
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-base italic">
|
||||||
|
<span v-html="work.title"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasContent(work)" class="flex gap-4 text-sm text-gray-500 mt-0.5">
|
||||||
|
<button v-if="work.soundcloud_trackid" @click="audioPlayerStore.setSoundCloudTrackID(work.soundcloud_trackid)" class="hover:underline">
|
||||||
|
audio
|
||||||
|
</button>
|
||||||
|
<button v-if="work.vimeo_trackid" @click="openVideoModal(work.vimeo_trackid)" class="hover:underline">
|
||||||
|
video
|
||||||
|
</button>
|
||||||
|
<button v-if="work.gallery" @click="openImageModal(work)" class="hover:underline">
|
||||||
|
images
|
||||||
|
</button>
|
||||||
|
<button v-if="work.score" @click="openScoreModal(work.score)" class="hover:underline">
|
||||||
|
score
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<section v-if="worksWithImages.length">
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="work in worksWithImages"
|
||||||
|
:key="work.title"
|
||||||
|
:to="'/works/' + slugify(work.title)"
|
||||||
|
class="block group relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<nuxt-img
|
||||||
|
:src="'/images/' + work.images[0].filename"
|
||||||
|
:alt="work.title"
|
||||||
|
class="w-full aspect-[4/3] object-cover transition-opacity duration-200 group-hover:opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 text-center italic px-2" v-html="work.title"></p>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const slugify = (title) => {
|
||||||
|
if (!title) return ''
|
||||||
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});
|
||||||
|
|
||||||
|
const hasItems = (work) => {
|
||||||
|
return work.vimeo_trackid || (work.images && work.images.length) || work.score
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContent = (work) => {
|
||||||
|
return work.soundcloud_trackid || work.vimeo_trackid || (work.images && work.images.length) || work.score
|
||||||
|
}
|
||||||
|
|
||||||
|
const openVideoModal = (vimeoId) => {
|
||||||
|
modalStore.setModalProps('video', 'aspect-video', true, '', '', vimeoId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openScoreModal = (scoreUrl) => {
|
||||||
|
modalStore.setModalProps('pdf', 'aspect-[1/1.414]', true, '', '', '', scoreUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openImageModal = (work) => {
|
||||||
|
const gallery = work.images.map(img => ({ image: img.filename }))
|
||||||
|
modalStore.setModalProps('image', 'aspect-auto', true, 'images', gallery, '', '', '', '', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const worksWithImages = computed(() => {
|
||||||
|
if (!works.value) return []
|
||||||
|
const allWorks = works.value.flatMap(group => group.works)
|
||||||
|
return allWorks.filter(work => work.images && work.images.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: works } = await useFetch('/api/works', {
|
||||||
|
key: 'works-pieces',
|
||||||
|
transform: (works) => {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(works))
|
||||||
|
for (const work of cloned) {
|
||||||
|
if(work.score){
|
||||||
|
work.score = "/scores/" + work.score
|
||||||
|
}
|
||||||
|
if(work.images){
|
||||||
|
let gallery = [];
|
||||||
|
for (const image of work.images){
|
||||||
|
gallery.push({
|
||||||
|
image: image.filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
work.gallery = gallery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let priorityGroups = groupBy(cloned, work => work.priority)
|
||||||
|
let groups = groupBy(priorityGroups["1"], work => new Date(work.date).getFullYear())
|
||||||
|
groups = Object.keys(groups).map((year) => {
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
works: groups[year].sort((a,b) => new Date(b.date) - new Date(a.date))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
groups.sort((a,b) => b.year - a.year)
|
||||||
|
if (priorityGroups["2"]) {
|
||||||
|
groups.push({year: "miscellany", works: priorityGroups["2"].sort((a,b) => new Date(b.date) - new Date(a.date))})
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Pieces'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
152
pages/works/[slug].vue
Normal file
152
pages/works/[slug].vue
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="work">
|
||||||
|
<div class="sticky top-14 bg-white z-30 border-b border-gray-200">
|
||||||
|
<div class="max-w-3xl mx-auto px-4">
|
||||||
|
<div class="flex items-center justify-between py-1">
|
||||||
|
<div class="flex flex-wrap items-baseline gap-1">
|
||||||
|
<h1 class="inline-block text-xl italic" v-html="work.title"></h1>
|
||||||
|
<span class="inline-block text-sm text-gray-500">({{ year }})</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex items-center gap-4 text-sm">
|
||||||
|
<a
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.href"
|
||||||
|
:href="item.href"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
<NuxtLink to="/pieces" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<Icon name="mdi:arrow-u-left-top" class="w-4 h-4 translate-y-0.5" />
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto space-y-8 px-4 pt-6">
|
||||||
|
<div v-if="work.soundcloud_trackid" id="audio">
|
||||||
|
<h2 class="text-lg mb-4 border-b border-gray-200 pb-2">Audio</h2>
|
||||||
|
<iframe
|
||||||
|
:src="'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true'"
|
||||||
|
width="100%"
|
||||||
|
height="150"
|
||||||
|
scrolling="no"
|
||||||
|
frameborder="no"
|
||||||
|
allow="autoplay"
|
||||||
|
class="w-full h-auto"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="work.vimeo_trackid" id="video">
|
||||||
|
<h2 class="text-lg mb-4 border-b border-gray-200 pb-2">Video</h2>
|
||||||
|
<iframe
|
||||||
|
:src="'https://player.vimeo.com/video/' + work.vimeo_trackid"
|
||||||
|
width="100%"
|
||||||
|
frameborder="0"
|
||||||
|
webkitallowfullscreen
|
||||||
|
mozallowfullscreen
|
||||||
|
allowfullscreen
|
||||||
|
class="w-full aspect-video h-auto"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="gallery?.length" id="images">
|
||||||
|
<h2 class="text-lg mb-4 border-b border-gray-200 pb-2">Images</h2>
|
||||||
|
<div :class="gallery.length === 1 ? 'grid grid-cols-1' : 'grid grid-cols-2 gap-4'">
|
||||||
|
<button
|
||||||
|
v-for="(img, index) in gallery"
|
||||||
|
:key="index"
|
||||||
|
@click="openImageModal(index)"
|
||||||
|
class="block w-full relative overflow-hidden group"
|
||||||
|
>
|
||||||
|
<nuxt-img
|
||||||
|
:src="'/images/' + img.image"
|
||||||
|
:alt="work.title"
|
||||||
|
class="w-full aspect-[4/3] object-cover transition-opacity duration-200 group-hover:opacity-50"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="scoreUrl" id="score">
|
||||||
|
<h2 class="text-lg mb-4 border-b border-gray-200 pb-2">Score</h2>
|
||||||
|
<iframe
|
||||||
|
:src="scoreUrl + '#toolbar=1&navpanes=0&sidebar=0'"
|
||||||
|
class="w-full h-[80vh] border border-gray-200"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center p-10">
|
||||||
|
<p>Work not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
const slug = route.params.slug
|
||||||
|
|
||||||
|
const slugify = (title) => {
|
||||||
|
if (!title) return ''
|
||||||
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: works } = await useFetch('/api/works', {
|
||||||
|
key: 'works-workpage-' + slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
const work = computed(() => {
|
||||||
|
if (!works.value) return null
|
||||||
|
return works.value.find(w => slugify(w.title) === slug)
|
||||||
|
})
|
||||||
|
|
||||||
|
const year = computed(() => {
|
||||||
|
if (!work.value?.date) return ''
|
||||||
|
return new Date(work.value.date).getFullYear()
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreUrl = computed(() => {
|
||||||
|
if (!work.value?.score) return null
|
||||||
|
if (work.value.score.startsWith('/scores/')) {
|
||||||
|
return work.value.score
|
||||||
|
}
|
||||||
|
return '/scores/' + work.value.score
|
||||||
|
})
|
||||||
|
|
||||||
|
const gallery = computed(() => {
|
||||||
|
if (!work.value?.images) return null
|
||||||
|
return work.value.images.map(img => ({
|
||||||
|
image: img.filename
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const navItems = computed(() => {
|
||||||
|
const items = []
|
||||||
|
if (work.value?.soundcloud_trackid) items.push({ label: 'Audio', href: '#audio' })
|
||||||
|
if (work.value?.vimeo_trackid) items.push({ label: 'Video', href: '#video' })
|
||||||
|
if (gallery.value?.length) items.push({ label: 'Images', href: '#images' })
|
||||||
|
if (scoreUrl.value) items.push({ label: 'Score', href: '#score' })
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const openImageModal = (index) => {
|
||||||
|
modalStore.setModalProps('image', 'aspect-auto', true, 'images', gallery.value, '', '', '', '', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: () => work.value ? `Michael Winter - ${work.value.title}` : 'Michael Winter'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-padding-top: 100px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
pages/writings.vue
Normal file
64
pages/writings.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<div v-for="item in pubs" :key="item.citationKey" class="mb-6">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
v-if="item.entryTags.howpublished"
|
||||||
|
:href="item.entryTags.howpublished"
|
||||||
|
target="_blank"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
<span v-html="item.entryTags.title"></span>
|
||||||
|
</a>
|
||||||
|
<span v-else v-html="item.entryTags.title"></span>
|
||||||
|
<div class="text-sm text-gray-500 ml-4">
|
||||||
|
{{ 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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const isValidUrl = urlString => {
|
||||||
|
var pattern = /^((http|https|ftp):\/\/)/;
|
||||||
|
return pattern.test(urlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: pubs } = await useFetch('/api/publications', {
|
||||||
|
key: 'publications-page',
|
||||||
|
transform: (pubs) => {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(pubs))
|
||||||
|
for (const pub of cloned) {
|
||||||
|
if(pub.entryTags && pub.entryTags.howpublished && !(isValidUrl(pub.entryTags.howpublished))){
|
||||||
|
pub.entryTags.howpublished = "/pubs/" + pub.entryTags.howpublished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned.sort((a, b) => {
|
||||||
|
const getYear = (key) => parseInt(key.replace(/\D/g, '')) || 0
|
||||||
|
const getSuffix = (key) => key.replace(/^Winter\d+/, '') || ''
|
||||||
|
|
||||||
|
const yearA = getYear(a.citationKey)
|
||||||
|
const yearB = getYear(b.citationKey)
|
||||||
|
|
||||||
|
if (yearA !== yearB) return yearB - yearA
|
||||||
|
|
||||||
|
return getSuffix(b.citationKey).localeCompare(getSuffix(a.citationKey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Writings'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -1052,7 +1052,6 @@
|
||||||
"variable ensemble"
|
"variable ensemble"
|
||||||
],
|
],
|
||||||
"date": "2004-04-02",
|
"date": "2004-04-02",
|
||||||
"score": "Tri_Dimensional_Canon_score.pdf",
|
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
"type": "sound"
|
"type": "sound"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
25
server/routes/scores/[...path].ts
Normal file
25
server/routes/scores/[...path].ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { existsSync, createReadStream } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { sendStream } from 'h3'
|
||||||
|
import { createError } from 'h3'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const url = event.path
|
||||||
|
|
||||||
|
// Get the filename from the URL
|
||||||
|
const filename = url.replace('/scores/', '')
|
||||||
|
|
||||||
|
// Check if file exists in public/scores/
|
||||||
|
const filePath = join(process.cwd(), 'public/scores', filename)
|
||||||
|
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Not Found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve the file
|
||||||
|
event.node.res.statusCode = 200
|
||||||
|
return sendStream(event, createReadStream(filePath))
|
||||||
|
})
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import {defineStore} from "pinia";
|
import {defineStore} from "pinia";
|
||||||
|
|
||||||
export const useAudioPlayerStore = defineStore("AudioPlayerStore", {
|
export const useAudioPlayerStore = defineStore("AudioPlayerStore", {
|
||||||
state: () => ({"soundcloud_trackid": "1032587794"}),
|
state: () => ({
|
||||||
|
"soundcloud_trackid": "undefined",
|
||||||
|
"currentTrackTitle": ""
|
||||||
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
setSoundCloudTrackID(trackid) {
|
setSoundCloudTrackID(trackid, title = "") {
|
||||||
if (typeof trackid !== 'undefined') {
|
if (typeof trackid !== 'undefined') {
|
||||||
this.soundcloud_trackid = trackid
|
this.soundcloud_trackid = trackid
|
||||||
|
this.currentTrackTitle = title
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearSoundCloudTrackID() {
|
clearSoundCloudTrackID() {
|
||||||
this.soundcloud_trackid = 'undefined'
|
this.soundcloud_trackid = 'undefined'
|
||||||
|
this.currentTrackTitle = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import {defineStore} from "pinia";
|
import {defineStore} from "pinia";
|
||||||
|
|
||||||
export const useModalStore = defineStore("ModalStore", {
|
export const useModalStore = defineStore("ModalStore", {
|
||||||
state: () => ({"type": "", "aspect":"", "isOpen":false, "bucket":"", "gallery":"", "vimeo_trackid": "", "pdfUrl": "", "soundcloudUrl": "", "iframeUrl": ""}),
|
state: () => ({"type": "", "aspect":"", "isOpen":false, "bucket":"", "gallery":"", "vimeo_trackid": "", "pdfUrl": "", "soundcloudUrl": "", "iframeUrl": "", "initialIndex": 0}),
|
||||||
actions: {
|
actions: {
|
||||||
setModalProps(type, aspect, isOpen, bucket, gallery, vimeo_trackid, pdfUrl, soundcloudUrl, iframeUrl) {
|
setModalProps(type, aspect, isOpen, bucket, gallery, vimeo_trackid, pdfUrl, soundcloudUrl, iframeUrl, initialIndex = 0) {
|
||||||
this.type = type
|
this.type = type
|
||||||
this.aspect = aspect
|
this.aspect = aspect
|
||||||
this.isOpen = isOpen
|
this.isOpen = isOpen
|
||||||
|
|
@ -13,6 +13,7 @@ export const useModalStore = defineStore("ModalStore", {
|
||||||
this.pdfUrl = pdfUrl
|
this.pdfUrl = pdfUrl
|
||||||
this.soundcloudUrl = soundcloudUrl
|
this.soundcloudUrl = soundcloudUrl
|
||||||
this.iframeUrl = iframeUrl
|
this.iframeUrl = iframeUrl
|
||||||
|
this.initialIndex = initialIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Loading…
Reference in a new issue