Add work pages with hash-based modal URLs and proper 404 handling

- Create dedicated /work/[slug] pages with sticky nav
- Use hash URLs (#type|slug) for modals instead of work page URLs
- Make works without items non-clickable on index
- Add server route to return 404 for non-existent score files
This commit is contained in:
Michael Winter 2026-03-06 15:08:30 +01:00
parent 635cbe0482
commit 6ffe5aa1fc
4 changed files with 197 additions and 19 deletions

View file

@ -2,9 +2,9 @@
<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" >
<NuxtLink v-if="type === 'score'" :to="'/work/' + workSlug" class="inline-flex p-1"> <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" />
</NuxtLink> </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"> <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">
<Icon name="ion:book-sharp" style="color: white" /> <Icon name="ion:book-sharp" style="color: white" />
@ -26,11 +26,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>
@ -70,4 +70,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' : '')
}
if (slug && typeof window !== 'undefined') {
window.history.replaceState({}, '', '/#' + hash)
}
}
</script> </script>

View file

@ -8,7 +8,12 @@
<p class="text-sm font-semibold mt-4 text-[#7F7F7F]">{{ item.year }}</p> <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="leading-tight py-1 ml-3" v-for="work in item.works">
<div class="grid grid-cols-[65%,30%] gap-1 items-start"> <div class="grid grid-cols-[65%,30%] gap-1 items-start">
<span v-html="work.title" class="italic text-sm"></span> <NuxtLink v-if="hasItems(work)" :to="'/work/' + slugify(work.title)" class="italic text-sm">
<span v-html="work.title"></span>
</NuxtLink>
<span v-else class="italic text-sm">
<span v-html="work.title"></span>
</span>
<div class="inline-flex mt-[-4px]"> <div class="inline-flex mt-[-4px]">
<div> <div>
@ -61,7 +66,7 @@
<p class="text-lg">albums</p> <p class="text-lg">albums</p>
<div class="flex flex-col items-center leading-tight py-4 text-sm" v-for="item in releases"> <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> <p class="leading-tight py-2">{{ item.title }}</p>
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: item.album_art}], '')"> <button @click="openAlbumModal(item)">
<nuxt-img :src="'/album_art/' + item.album_art" <nuxt-img :src="'/album_art/' + item.album_art"
quality="50"/> quality="50"/>
</button> </button>
@ -77,11 +82,12 @@
<script setup> <script setup>
import { useModalStore } from "@/stores/ModalStore" import { useModalStore } from "@/stores/ModalStore"
import { watchEffect } from "vue" import { watchEffect, onMounted } from "vue"
const props = defineProps(['slug']) const props = defineProps(['slug'])
const modalStore = useModalStore() const modalStore = useModalStore()
const route = useRoute()
const slugify = (title) => { const slugify = (title) => {
if (!title) return '' if (!title) return ''
@ -90,6 +96,10 @@
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{}); 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 => { const isValidUrl = urlString => {
var pattern = /^((http|https|ftp):\/\/)/; var pattern = /^((http|https|ftp):\/\/)/;
return pattern.test(urlString) return pattern.test(urlString)
@ -173,6 +183,49 @@
return null return null
} }
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' : ''
)
}
}
watchEffect(() => { watchEffect(() => {
if (props.slug && works.value) { if (props.slug && works.value) {
const work = findWorkBySlug(props.slug) const work = findWorkBySlug(props.slug)
@ -190,4 +243,16 @@
} }
} }
}) })
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> </script>

View file

@ -1,20 +1,91 @@
<template> <template>
<IndexContent :slug="slug" /> <div class="bg-zinc-100 rounded-lg mx-5 mt-5 p-5">
<div v-if="work">
<div class="sticky top-[100px] z-10 bg-zinc-100 pb-4 pt-2">
<div class="max-w-[800px] mx-auto">
<h1 class="text-4xl italic text-center mb-4" v-html="work.title"></h1>
<nav v-if="itemCount >= 2" class="bg-zinc-200 flex gap-2 p-2 rounded-lg shadow border border-zinc-300 mb-6">
<a v-if="work.vimeo_trackid" href="#video" class="px-4 py-2 bg-white rounded hover:bg-gray-200 transition font-medium">Video</a>
<a v-if="gallery && gallery.length" href="#images" class="px-4 py-2 bg-white rounded hover:bg-gray-200 transition font-medium">Images</a>
<a v-if="scoreUrl" href="#score" class="px-4 py-2 bg-white rounded hover:bg-gray-200 transition font-medium">Score</a>
</nav>
</div>
</div>
<div class="max-w-[800px] mx-auto">
<div id="video" v-if="work.vimeo_trackid" class="mb-8 scroll-mt-[280px]">
<iframe :src="'https://player.vimeo.com/video/' + work.vimeo_trackid" width="100%" height="100%" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen class="w-full aspect-video"></iframe>
</div>
<div id="images" v-if="gallery && gallery.length" class="mb-8 scroll-mt-[280px]">
<ImageSlider :bucket="'images'" :gallery="gallery"></ImageSlider>
</div>
<div id="score" v-if="scoreUrl" class="mb-8 scroll-mt-[280px]">
<iframe :src="scoreUrl + '#toolbar=1&navpanes=0&sidebar=0'" class="w-full h-[85vh] border"></iframe>
</div>
</div>
</div>
<div v-else class="text-center p-10">
<p>Work not found</p>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { useModalStore } from "@/stores/ModalStore" import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
import { watch } from "vue"
const route = useRoute() const route = useRoute()
const modalStore = useModalStore() const audioPlayerStore = useAudioPlayerStore()
const router = useRouter() const slug = route.params.slug
const slug = route.params.slug const slugify = (title) => {
if (!title) return ''
return title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
watch(() => modalStore.isOpen, (isOpen) => { const { data: works } = await useFetch('/api/works', {
if (!isOpen) { key: 'works-workpage-' + slug,
router.push('/') })
const work = computed(() => {
if (!works.value) return null
return works.value.find(w => slugify(w.title) === slug)
})
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 itemCount = computed(() => {
let count = 0
if (work.value?.vimeo_trackid) count++
if (gallery.value?.length) count++
if (scoreUrl.value) count++
return count
})
onMounted(() => {
if (work.value?.soundcloud_trackid) {
audioPlayerStore.setSoundCloudTrackID(work.value.soundcloud_trackid)
}
})
</script> </script>
<style>
html {
scroll-behavior: smooth;
}
</style>

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