Merge redesign into main

This commit is contained in:
Michael Winter 2026-03-08 19:27:52 +01:00
commit 1ca6cb8646
32 changed files with 1306 additions and 1112 deletions

View file

@ -1,5 +1,5 @@
<template>
<div class="bg-white min-w-[800px] min-h-[80vh]">
<div class="bg-white min-h-[80vh]">
<NuxtLayout>
<NuxtPage/>
</NuxtLayout>

View file

@ -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>

View file

@ -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 = {}

View file

@ -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>

View file

@ -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,
}

View file

@ -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>

View file

@ -2,13 +2,17 @@
<div class="inline-flex p-1 min-w-[25px]">
<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" />
</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 -->
<span v-if="type === 'document'">
<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">
<Icon name="bxs:purchase-tag" style="color: white" />
@ -26,11 +30,11 @@
<Icon name="wpf:speaker" style="color: white" />
</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" />
</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" />
</button>
@ -48,6 +52,13 @@
const audioPlayerStore = useAudioPlayerStore()
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(() => {
return props.link && !props.link.endsWith('.pdf') && !props.link.startsWith('/')
})
@ -63,4 +74,21 @@
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>

View file

@ -4,6 +4,7 @@
:loop="true"
:spaceBetween="30"
:centeredSlides="true"
:initialSlide="initialIndex"
:autoplay="{
delay: 4000,
disableOnInteraction: false,
@ -33,6 +34,6 @@
<script>
export default {
props: ['gallery', 'bucket']
props: ['gallery', 'bucket', 'initialIndex']
}
</script>

View 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
View 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
View 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>

View file

@ -1,3 +0,0 @@
<template>
<div class="fixed inset-0 bg-black/50 z-15 transition duration-300" />
</template>

View file

@ -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>

View file

@ -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>

View file

@ -1,9 +0,0 @@
<script setup lang="ts">
// import { ref } from 'vue'
</script>
<template>
<div class="px-4 py-3">
<slot />
</div>
</template>

View file

@ -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"
>
&times;
</button>
</slot>
</DialogTitle>
</template>

View 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>

View file

@ -1,86 +1,175 @@
<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">
<!-- Mobile header -->
<header class="sticky top-0 bg-white z-40 border-b border-gray-200 lg:hidden">
<div class="max-w-7xl mx-auto px-4 py-2 flex items-center justify-between gap-4">
<Menu />
<h1 class="text-lg md:text-2xl whitespace-nowrap">
<NuxtLink to='/' class="hover:underline">michael winter</NuxtLink>
</h1>
<div class="flex-1 text-right text-lg md:text-xl">{{ pageTitle }}</div>
</div>
</header>
<!-- Desktop header -->
<header class="sticky top-0 bg-white z-40 border-b border-gray-200 hidden lg:block">
<div class="w-full px-4 py-2 flex items-center justify-between">
<h1 class="text-lg md:text-2xl whitespace-nowrap">
<NuxtLink to='/' class="hover:underline">michael winter</NuxtLink>
</h1>
<div class="text-lg md:text-xl">{{ pageTitle }}</div>
</div>
</header>
<div class="flex">
<!-- Sidebar for wide screens -->
<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">
<nav class="space-y-4 ml-4">
<div>
<div class="text-5xl p-2"> <NuxtLink to='/'>michael winter</NuxtLink></div>
<div class="inline-flex text-2xl ml-4">
<NuxtLink class="px-3" to='/'>works</NuxtLink>
<NuxtLink class="px-3" to='/events'>events</NuxtLink>
<NuxtLink class="px-3" to='/about'>about</NuxtLink>
<NuxtLink class="px-3" to='https://unboundedpress.org/code'>code</NuxtLink>
<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">pieces</NuxtLink>
<NuxtLink to="/writings" class="block hover:underline">writings</NuxtLink>
<NuxtLink to="/albums" class="block hover:underline">albums</NuxtLink>
</div>
</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>
<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">performances</NuxtLink>
<NuxtLink to="/lectures" class="block hover:underline">lectures</NuxtLink>
</div>
--->
</div>
<!-- TODO: this needs to be automatically flipped off when there are no upcoming events-->
<!------
<div class="px-1 bg-zinc-100 rounded-lg text-center">
<div class="text-sm">upcoming events</div>
<EventSlider :upcoming_events="upcoming_events" class="max-w-[95%] min-h-[80%]"></EventSlider>
</div>
-->
<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>
<slot /> <!-- required here only -->
<div class="fixed bottom-0 bg-white p-2 w-full flex justify-center z-20">
</div>
<footer class="fixed bottom-0 bg-white border-t border-gray-200 p-2 w-full flex justify-center z-30">
<ClientOnly>
<iframe width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay" v-if="audioPlayerStore.soundcloud_trackid !== 'undefined'"
:src="'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + audioPlayerStore.soundcloud_trackid + '&inverse=false&auto_play=true&show_user=false'"></iframe>
<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>
</div>
</footer>
<Modal v-model="modalStore.isOpen" :maxHeight="modalStore.type === 'image' && modalStore.soundcloudUrl ? 'calc(85vh + 60px)' : '85vh'">
<ModalBody :class="modalStore.aspect">
<ImageSlider v-if="modalStore.type === 'image'" :bucket="modalStore.bucket" :gallery="modalStore.gallery"></ImageSlider>
<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">
<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>
</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>
<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>
</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>
<iframe :src="modalStore.iframeUrl" width="100%" height="100%" frameborder="0" class="flex-grow"></iframe>
</ClientOnly>
</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>
<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>
<div v-if="modalStore.soundcloudUrl" class="flex justify-center mt-2">
<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>
</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">
<Icon name="mdi:open-in-new" class="w-5 h-5 text-white" />
</a>
</div>
</ModalBody>
</Modal>
</div>
</SimpleModal>
</div>
</template>
<script setup>
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
import { useModalStore } from "@/stores/ModalStore"
import { onMounted } from "vue"
import { onMounted, computed, ref } from "vue"
const audioPlayerStore = useAudioPlayerStore()
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 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(() => {
if(route.params.files == 'scores') {
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>

View file

@ -19,6 +19,7 @@ export default defineNuxtConfig({
},
},
routeRules: {
'/': { redirect: '/pieces' },
'/hdp': { redirect: '/a_history_of_the_domino_problem' },
// Default: prerender all routes (static HTML)
@ -32,6 +33,6 @@ export default defineNuxtConfig({
prerender: { crawlLinks: true}
},
experimental: {
payloadExtraction: true
payloadExtraction: false
}
})

View file

@ -1,91 +1,98 @@
<template>
<div class="bg-zinc-100 rounded-lg m-5 grid grid-cols-[60%,35%] gap-10 divide-x divide-solid divide-black py-4 min-h-[calc(100vh-10.5rem)]">
<div class="px-5">
<p class="text-lg">about</p>
<div class="leading-tight py-2 ml-3 text-sm">
<div class="leading-tight py-2">
<div class="grid grid-cols-1 lg:grid-cols-5 gap-8">
<div class="lg:col-span-3">
<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.
</div>
<div class="leading-tight py-2">
</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>
<br>
<br>
<div class="mb-8 space-y-2">
<a href="mailto:mwinter@unboundedpress.org" class="block hover:underline text-gray-500">contact</a>
<a href="/cv" class="block hover:underline text-gray-500">cv</a>
<a href="/works_list" class="block hover:underline text-gray-500">works list</a>
</div>
<div id="mc_embed_signup">
<form action="https://unboundedpress.us12.list-manage.com/subscribe/post?u=bdadd25738fedf704641f3a80&amp;id=01c5761ebb&amp;f_id=00f143e0f0" method="post" id="mc-embedded-subscribe-form" name="mc-embedded-subscribe-form" class="validate" target="_self">
<label for="mce-EMAIL">subscribe to my mailing list to know about upcoming events</label>
<input id="mce-EMAIL" type="email" value="" name="EMAIL" placeholder="email address" required="" class="email">
<label for="mce-EMAIL" class="block mb-2">subscribe to my mailing list to know about upcoming events</label>
<div class="flex gap-2">
<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">
<div style="position: absolute; left: -5000px;" aria-hidden="true">
<input type="text" name="b_bdadd25738fedf704641f3a80_01c5761ebb" tabindex="-1" value="">
</div>
<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>
</section>
</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>
<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 class="px-5">
<ImageSlider bucket="images" :gallery="gallery" class="max-w-[90%]"></ImageSlider>
</section>
</div>
</div>
</template>
<script setup>
import { useModalStore } from "@/stores/ModalStore"
const modalStore = useModalStore()
const { data: gallery } = await useFetch('/api/my_image_gallery')
const openImageModal = (index) => {
modalStore.setModalProps('image', 'aspect-auto', true, 'images', gallery.value, '', '', '', '', index)
}
useHead({
titleTemplate: 'Michael Winter - About - Short Bio, Contact, CV, Works List, and Mailing List'
})
titleTemplate: 'Michael Winter - About'
})
</script>
<style>
#mc_embed_signup form {text-align:left; padding:2px 0 2px 0;}
.mc-field-group { display: inline-block; } /* positions input field horizontally */
#mc_embed_signup input.email {border: 1px solid #ABB0B2; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; color: #343434; background-color: #fff; box-sizing:border-box; height:32px; padding: 0px 0.4em; display: inline-block; margin: 0; width:350px; vertical-align:top;}
#mc_embed_signup label {display:block; padding-bottom:10px;}
#mc_embed_signup .clear {display: inline-block;} /* positions button horizontally in line with input */
#mc_embed_signup .button {font-size: 13px; border: none; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; letter-spacing: .03em; color: #fff; background-color: #aaa; box-sizing:border-box; height:32px; line-height:32px; padding:0 18px; display: inline-block; margin: 0; transition: all 0.23s ease-in-out 0s;}
#mc_embed_signup .button:hover {background-color:#777; cursor:pointer;}
#mc_embed_signup div#mce-responses {float:left; top:-1.4em; padding:0em .5em 0em .5em; overflow:hidden; width:90%;margin: 0 5%; clear: both;}
#mc_embed_signup div.response {margin:1em 0; padding:1em .5em .5em 0; font-weight:bold; float:left; top:-1.5em; z-index:1; width:80%;}
#mc_embed_signup #mce-error-response {display:none;}
#mc_embed_signup #mce-success-response {color:#529214; display:none;}
#mc_embed_signup label.error {display:block; float:none; width:auto; margin-left:1.05em; text-align:left; padding:.5em 0;}
@media (max-width: 768px) {
#mc_embed_signup input.email {width:100%; margin-bottom:5px;}
#mc_embed_signup .clear {display: block; width: 100% }
#mc_embed_signup .button {width: 100%; margin:0; }
<style scoped>
#mc_embed_signup input.email {
width: 100%;
}
@media (min-width: 640px) {
#mc_embed_signup input.email {
width: auto;
flex: 1;
}
#mc_embed_signup{clear:left; width:100%;}
}
#mc_embed_signup .button {
width: auto;
}
@media (max-width: 639px) {
#mc_embed_signup form > div {
flex-direction: column;
}
#mc_embed_signup .button {
width: 100%;
}
}
</style>

62
pages/albums.vue Normal file
View 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>

View file

@ -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>

View file

@ -1,167 +1,7 @@
<template>
<div class="bg-zinc-100 rounded-lg m-5 grid grid-cols-3 gap-10 divide-x divide-solid divide-black py-4 mb-10">
<div class="px-5">
<p class="text-lg">pieces</p>
<div class="py-2 ml-3" v-for="item in works">
<p class="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}}.&nbsp;</span>
<span v-if=item.entryTags.journal>{{item.entryTags.journal}}.&nbsp;</span>
<span v-if=item.entryTags.editor>editors {{item.entryTags.editor}}&nbsp;</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>
<div></div>
</template>
<script setup>
import { useModalStore } from "@/stores/ModalStore"
const modalStore = useModalStore()
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});
const isValidUrl = urlString => {
var 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'
})
navigateTo('/pieces')
</script>

60
pages/lectures.vue Normal file
View 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
View 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
View 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
View 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
View 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>

View file

@ -1052,7 +1052,6 @@
"variable ensemble"
],
"date": "2004-04-02",
"score": "Tri_Dimensional_Canon_score.pdf",
"priority": 2,
"type": "sound"
},

View 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))
})

View file

@ -1,15 +1,20 @@
import {defineStore} from "pinia";
export const useAudioPlayerStore = defineStore("AudioPlayerStore", {
state: () => ({"soundcloud_trackid": "1032587794"}),
state: () => ({
"soundcloud_trackid": "undefined",
"currentTrackTitle": ""
}),
actions: {
setSoundCloudTrackID(trackid) {
setSoundCloudTrackID(trackid, title = "") {
if (typeof trackid !== 'undefined') {
this.soundcloud_trackid = trackid
this.currentTrackTitle = title
}
},
clearSoundCloudTrackID() {
this.soundcloud_trackid = 'undefined'
this.currentTrackTitle = ""
}
}
})

View file

@ -1,9 +1,9 @@
import {defineStore} from "pinia";
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: {
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.aspect = aspect
this.isOpen = isOpen
@ -13,6 +13,7 @@ export const useModalStore = defineStore("ModalStore", {
this.pdfUrl = pdfUrl
this.soundcloudUrl = soundcloudUrl
this.iframeUrl = iframeUrl
this.initialIndex = initialIndex
}
}
})