2026-03-06 10:23:45 +01:00
|
|
|
<template>
|
2026-03-07 11:28:55 +01:00
|
|
|
<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>
|
2026-03-06 10:23:45 +01:00
|
|
|
|
2026-03-07 11:28:55 +01:00
|
|
|
<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>
|
2026-03-06 10:23:45 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-07 11:28:55 +01:00
|
|
|
</section>
|
2026-03-06 10:23:45 +01:00
|
|
|
|
2026-03-07 11:28:55 +01:00
|
|
|
<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>
|
2026-03-06 10:23:45 +01:00
|
|
|
</div>
|
2026-03-07 11:28:55 +01:00
|
|
|
<IconButton
|
|
|
|
|
v-if="item.entryTags.howpublished"
|
|
|
|
|
type="document"
|
|
|
|
|
:link="item.entryTags.howpublished"
|
|
|
|
|
></IconButton>
|
2026-03-06 10:23:45 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-07 11:28:55 +01:00
|
|
|
</section>
|
2026-03-06 10:23:45 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-07 11:28:55 +01:00
|
|
|
<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>
|
2026-03-06 10:23:45 +01:00
|
|
|
</div>
|
2026-03-07 11:28:55 +01:00
|
|
|
</section>
|
2026-03-06 10:23:45 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { useModalStore } from "@/stores/ModalStore"
|
2026-03-06 15:08:30 +01:00
|
|
|
import { watchEffect, onMounted } from "vue"
|
2026-03-06 10:23:45 +01:00
|
|
|
|
|
|
|
|
const props = defineProps(['slug'])
|
|
|
|
|
|
|
|
|
|
const modalStore = useModalStore()
|
2026-03-06 15:08:30 +01:00
|
|
|
const route = useRoute()
|
2026-03-06 10:23:45 +01:00
|
|
|
|
|
|
|
|
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),{});
|
|
|
|
|
|
2026-03-06 15:08:30 +01:00
|
|
|
const hasItems = (work) => {
|
|
|
|
|
return work.vimeo_trackid || (work.images && work.images.length) || work.score
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 10:23:45 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 15:08:30 +01:00
|
|
|
const openAlbumModal = (album) => {
|
|
|
|
|
modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: album.album_art}], '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' : ''
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 10:23:45 +01:00
|
|
|
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' : ''
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-06 15:08:30 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-06 10:23:45 +01:00
|
|
|
</script>
|