Add shareable work pages with modal support
- Create IndexContent component to avoid code duplication - Score icon links to /work/[slug] with clean URLs - Modal opens automatically via slug prop (works with SSR) - Crawler finds and prerenders all /work/ pages (145 routes) - Work page redirects to / when modal closes - Single source of truth for data fetching in IndexContent
This commit is contained in:
parent
9af20c1e5d
commit
635cbe0482
|
|
@ -2,9 +2,9 @@
|
|||
<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="openScoreModal()" class="inline-flex p-1">
|
||||
<NuxtLink v-if="type === 'score'" :to="'/work/' + workSlug" class="inline-flex p-1">
|
||||
<Icon name="ion:book-sharp" style="color: white" />
|
||||
</button>
|
||||
</NuxtLink>
|
||||
|
||||
<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" />
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
<script setup>
|
||||
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||
import { useModalStore } from "@/stores/ModalStore"
|
||||
import { computed, watch } from "vue"
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps(['type', 'work', 'visible', 'link', 'newTab'])
|
||||
|
||||
|
|
@ -70,17 +70,4 @@
|
|||
modalStore.setModalProps('document', 'aspect-[1/1.414]', true, '', '', '', '', '', props.link)
|
||||
}
|
||||
}
|
||||
|
||||
const openScoreModal = () => {
|
||||
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' : '')
|
||||
if (workSlug.value && typeof window !== 'undefined') {
|
||||
window.history.replaceState({}, '', '/?work=' + workSlug.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => modalStore.isOpen, (isOpen) => {
|
||||
if (!isOpen && workSlug.value && typeof window !== 'undefined' && window.location.search.includes('work=')) {
|
||||
window.history.replaceState({}, '', '/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
193
components/IndexContent.vue
Normal file
193
components/IndexContent.vue
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<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}}. </span>
|
||||
<span v-if=item.entryTags.journal>{{item.entryTags.journal}}. </span>
|
||||
<span v-if=item.entryTags.editor>editors {{item.entryTags.editor}} </span>
|
||||
<span v-if=item.entryTags.volume>volume {{item.entryTags.volume}}.</span>
|
||||
<span v-if=item.entryTags.publisher>{{item.entryTags.publisher}}.</span>
|
||||
{{ item.entryTags.year }}.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton :visible=item.entryTags.howpublished type="document" :link=item.entryTags.howpublished class="inline-flex p-1 mt-[-6px]"></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5">
|
||||
<p class="text-lg">albums</p>
|
||||
<div class="flex flex-col items-center leading-tight py-4 text-sm" v-for="item in releases">
|
||||
<p class="leading-tight py-2">{{ item.title }}</p>
|
||||
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: item.album_art}], '')">
|
||||
<nuxt-img :src="'/album_art/' + item.album_art"
|
||||
quality="50"/>
|
||||
</button>
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<IconButton :visible="item.discogs_id" type="discogs" :link="'https://www.discogs.com/release/' + item.discogs_id" :newTab="true"></IconButton>
|
||||
<IconButton :visible="item.buy_link" type="buy" :link="item.buy_link" :newTab="true"></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useModalStore } from "@/stores/ModalStore"
|
||||
import { watchEffect } from "vue"
|
||||
|
||||
const props = defineProps(['slug'])
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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' : ''
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
201
pages/index.vue
201
pages/index.vue
|
|
@ -1,208 +1,9 @@
|
|||
<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}}. </span>
|
||||
<span v-if=item.entryTags.journal>{{item.entryTags.journal}}. </span>
|
||||
<span v-if=item.entryTags.editor>editors {{item.entryTags.editor}} </span>
|
||||
<span v-if=item.entryTags.volume>volume {{item.entryTags.volume}}.</span>
|
||||
<span v-if=item.entryTags.publisher>{{item.entryTags.publisher}}.</span>
|
||||
{{ item.entryTags.year }}.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton :visible=item.entryTags.howpublished type="document" :link=item.entryTags.howpublished class="inline-flex p-1 mt-[-6px]"></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5">
|
||||
<p class="text-lg">albums</p>
|
||||
<div class="flex flex-col items-center leading-tight py-4 text-sm" v-for="item in releases">
|
||||
<p class="leading-tight py-2">{{ item.title }}</p>
|
||||
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: item.album_art}], '')">
|
||||
<nuxt-img :src="'/album_art/' + item.album_art"
|
||||
quality="50"/>
|
||||
</button>
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<IconButton :visible="item.discogs_id" type="discogs" :link="'https://www.discogs.com/release/' + item.discogs_id" :newTab="true"></IconButton>
|
||||
<IconButton :visible="item.buy_link" type="buy" :link="item.buy_link" :newTab="true"></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<IndexContent />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { useModalStore } from "@/stores/ModalStore"
|
||||
import { watch, watchEffect } from "vue"
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
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 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'
|
||||
})
|
||||
|
||||
const workFromSlug = computed(() => {
|
||||
const workSlug = route.query.work
|
||||
if (!workSlug || !works.value) return null
|
||||
for (const group of works.value) {
|
||||
const found = group.works.find(w => slugify(w.title) === workSlug)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const workSlug = route.query.work
|
||||
const work = workFromSlug.value
|
||||
if (workSlug && 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' : ''
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => modalStore.isOpen, (isOpen) => {
|
||||
if (!isOpen && route.query.work && typeof window !== 'undefined') {
|
||||
window.history.replaceState({}, '', '/')
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
<template>
|
||||
<IndexContent :slug="slug" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useModalStore } from "@/stores/ModalStore"
|
||||
import { watch } from "vue"
|
||||
|
||||
const route = useRoute()
|
||||
const modalStore = useModalStore()
|
||||
const router = useRouter()
|
||||
|
||||
const slug = route.params.slug
|
||||
|
||||
onMounted(() => {
|
||||
router.replace('/?work=' + slug)
|
||||
watch(() => modalStore.isOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in a new issue