Initial commit: Nuxt portfolio with local data
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
node_modules
|
||||||
|
*.log*
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN apk add bash
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
ENV NITRO_HOST=0.0.0.0
|
||||||
|
ENV NITRO_PORT=5000
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# ENTRYPOINT ["npm", "run", "build", "node", ".output/server/index.mjs"]
|
||||||
34
Dockerfile_upgrade
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Build Stage 1
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration
|
||||||
|
COPY package.json .npmrc ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm i
|
||||||
|
|
||||||
|
# Copy the entire project
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# Build Stage 2
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Only `.output` folder is needed from the build stage
|
||||||
|
COPY --from=build /src/.output/ ./
|
||||||
|
|
||||||
|
# Change the port and host
|
||||||
|
ENV PORT=5000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["node", "/app/server/index.mjs"]
|
||||||
25
app.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<template>
|
||||||
|
<div class="bg-white min-w-[800px] min-h-[80vh]">
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage/>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active {
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.page-enter-from,
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(1rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
assets/hdp_background.png
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
313
components/Collapsible/CollapseTransition.vue
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
<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>
|
||||||
25
components/Collapsible/Collapsible.stories.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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 = {}
|
||||||
91
components/Collapsible/Collapsible.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
38
components/Collapsible/CollapsibleGroup.stories.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
55
components/Collapsible/CollapsibleGroup.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
|
import { ref, toRefs, watch } from 'vue'
|
||||||
|
import Icon from '../Icon/index.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>
|
||||||
61
components/EventSlider.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<template>
|
||||||
|
<Swiper
|
||||||
|
:autoHeight="true"
|
||||||
|
:loop="true"
|
||||||
|
:spaceBetween="30"
|
||||||
|
:centeredSlides="true"
|
||||||
|
:autoplay="{
|
||||||
|
delay: 6000,
|
||||||
|
disableOnInteraction: false,
|
||||||
|
pauseOnMouseEnter: true
|
||||||
|
}"
|
||||||
|
:effect="'fade'"
|
||||||
|
:fadeEffect="{
|
||||||
|
crossFade: true
|
||||||
|
}"
|
||||||
|
:pagination="{
|
||||||
|
clickable: true,
|
||||||
|
}"
|
||||||
|
|
||||||
|
:style="{
|
||||||
|
'--swiper-navigation-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-bottom': '0%'
|
||||||
|
}"
|
||||||
|
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation, SwiperEffectFade]"
|
||||||
|
>
|
||||||
|
|
||||||
|
<SwiperSlide v-for="(item, index) in upcoming_events" class="bg-zinc-100 h-full">
|
||||||
|
<div class="gap-1 w-[100%] mt-1 mb-1 text-sm h-full">
|
||||||
|
<div>
|
||||||
|
{{ item.formatted_date }}: {{item.venue.city}}, {{item.venue.state}}
|
||||||
|
<div class="text-[#7F7F7F]">
|
||||||
|
{{ item.venue.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Comment
|
||||||
|
<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-for="(instrument, index) in performer.instrument_tags">
|
||||||
|
<span v-if="index !== 0">, </span>
|
||||||
|
{{ instrument }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</SwiperSlide>
|
||||||
|
|
||||||
|
</Swiper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['upcoming_events']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
54
components/IconButton.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<template>
|
||||||
|
<div class="inline-flex p-1 min-w-[25px]">
|
||||||
|
<div v-show="visible" class="bg-black rounded-full text-xs inline-flex" >
|
||||||
|
|
||||||
|
<NuxtLink v-if="type === 'score'" :to="link" class="inline-flex p-1">
|
||||||
|
<Icon name="ion:book-sharp" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'document'" class="inline-flex p-1" :to="link" :target="newTab ? '_blank' : undefined">
|
||||||
|
<Icon name="ion:book-sharp" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'buy'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="bxs:purchase-tag" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'email'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="ic:baseline-email" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'discogs'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="simple-icons:discogs" color="white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<button @click="audioPlayerStore.setSoundCloudTrackID(work.soundcloud_trackid)" v-else-if="type === 'audio'" class="inline-flex p-1">
|
||||||
|
<Icon name="wpf:speaker" color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="modalStore.setModalProps('video', 'aspect-video', true, '', '', work.vimeo_trackid)" v-else-if="type === 'video'" class="inline-flex p-1">
|
||||||
|
<Icon name="fluent:video-48-filled" color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'images', work.gallery, '')" v-else="type === 'image'" class="inline-flex p-1">
|
||||||
|
<Icon name="mdi:camera" color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['type', 'work', 'visible', 'link', 'newTab']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
38
components/ImageSlider.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<template>
|
||||||
|
<Swiper
|
||||||
|
:autoHeight="true"
|
||||||
|
:loop="true"
|
||||||
|
:spaceBetween="30"
|
||||||
|
:centeredSlides="true"
|
||||||
|
:autoplay="{
|
||||||
|
delay: 4000,
|
||||||
|
disableOnInteraction: false,
|
||||||
|
pauseOnMouseEnter: true
|
||||||
|
}"
|
||||||
|
:pagination="{
|
||||||
|
clickable: true,
|
||||||
|
}"
|
||||||
|
:navigation="true"
|
||||||
|
:style="{
|
||||||
|
'--swiper-navigation-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-color': 'rgb(71 85 105)',
|
||||||
|
'--swiper-pagination-bottom': 'auto',
|
||||||
|
'--swiper-pagination-top': '1rem',
|
||||||
|
'--swiper-navigation-top-offset': '5rem'
|
||||||
|
}"
|
||||||
|
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation]"
|
||||||
|
class="h-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
|
||||||
|
<SwiperSlide v-for="image in gallery" class="!flex !items-center !justify-center !h-auto !py-10 !bg-zinc-100">
|
||||||
|
<img :src="'/' + bucket + '/' + image.image"
|
||||||
|
style="max-width: 100%; max-height: 70vh; object-fit: contain;"/>
|
||||||
|
</SwiperSlide>
|
||||||
|
</Swiper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['gallery', 'bucket']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
3
components/Modal/Backdrop.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-black/50 z-15 transition duration-300" />
|
||||||
|
</template>
|
||||||
110
components/Modal/Modal.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<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
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: false,
|
||||||
|
persistent: false,
|
||||||
|
fullscreen: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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-[85vw] rounded-lg': !fullscreen,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
|
||||||
|
</template>
|
||||||
9
components/Modal/ModalBody.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<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>
|
||||||
9
components/Modal/ModalFooter.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
// import { ref } from 'vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
34
components/Modal/ModalHeader.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogTitle } from '@headlessui/vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dismissable?: boolean
|
||||||
|
titleClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const api = inject('modal')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
as="div"
|
||||||
|
class="flex gap-2 justify-between items-center px-4 pt-3"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-lg font-medium leading-6 text-gray-900"
|
||||||
|
:class="titleClass"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
<slot v-if="dismissable" name="dismissable">
|
||||||
|
<button
|
||||||
|
class="text-2xl text-gray-500 appearance-none px-2 -mr-2"
|
||||||
|
@click="api.close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
57
export-gridfs.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
const { MongoClient, GridFSBucket } = require('mongodb');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function exportGridFS() {
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const db = client.db('portfolio');
|
||||||
|
|
||||||
|
const buckets = ['images', 'album_art', 'scores', 'pubs'];
|
||||||
|
|
||||||
|
for (const bucketName of buckets) {
|
||||||
|
const bucket = new GridFSBucket(db, { bucketName });
|
||||||
|
const outputDir = path.join(__dirname, 'public', bucketName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(outputDir)) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = bucket.find({});
|
||||||
|
const files = await cursor.toArray();
|
||||||
|
|
||||||
|
console.log(`Exporting ${files.length} files from ${bucketName}...`);
|
||||||
|
|
||||||
|
const promises = files.map(file => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const outputPath = path.join(outputDir, file.filename);
|
||||||
|
const stream = bucket.openDownloadStreamByName(file.filename);
|
||||||
|
const writeStream = fs.createWriteStream(outputPath);
|
||||||
|
|
||||||
|
stream.pipe(writeStream);
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
console.log(` Saved: ${file.filename}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
console.error(` Error saving ${file.filename}:`, err.message);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
console.log(`Finished ${bucketName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done!');
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportGridFS();
|
||||||
78
layouts/default.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[63%,35%] w-full font-thin sticky top-0 bg-white p-2 z-20">
|
||||||
|
<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>
|
||||||
|
<NuxtLink class="px-3 block" to='https://unboundedpress.org/legacy'>legacy</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- hdp link while active -->
|
||||||
|
<!------
|
||||||
|
<div class="inline-flex text-2xl ml-4 font-bold">
|
||||||
|
<NuxtLink class="px-3" to='/a_history_of_the_domino_problem'>A HISTORY OF THE DOMINO PROBLEM | 17.11 - 01.12.2023 </NuxtLink>
|
||||||
|
</div>
|
||||||
|
--->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
-->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<slot /> <!-- required here only -->
|
||||||
|
<div class="fixed bottom-0 bg-white p-2 w-full flex justify-center z-20">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-model="modalStore.isOpen">
|
||||||
|
<ModalBody :class="modalStore.aspect">
|
||||||
|
<ImageSlider v-if="modalStore.type === 'image'" :bucket="modalStore.bucket" :gallery="modalStore.gallery"></ImageSlider>
|
||||||
|
<iframe v-if="modalStore.type === 'video'" :src="'https://player.vimeo.com/video/' + modalStore.vimeo_trackid" width="100%" height="100%" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
if(route.params.files == 'scores') {
|
||||||
|
const { data: works } = await useFetch('/api/works', {
|
||||||
|
transform: (works) => {
|
||||||
|
return works.find(w => w.score === route.params.filename)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if(works.value?.soundcloud_trackid){
|
||||||
|
audioPlayerStore.setSoundCloudTrackID(works.value.soundcloud_trackid)
|
||||||
|
} else {
|
||||||
|
audioPlayerStore.clearSoundCloudTrackID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
5
layouts/plain.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
33
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//import { defineNuxtConfig } from 'nuxt3'
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: ['@nuxtjs/tailwindcss', '@nuxt/image', 'nuxt-icon', '@pinia/nuxt', 'nuxt-headlessui', 'nuxt-swiper'],
|
||||||
|
extends: ['nuxt-umami'],
|
||||||
|
image: {
|
||||||
|
domains: ['unboundedpress.org']
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
//baseURL: "/dev/",
|
||||||
|
pageTransition: { name: 'page', mode: 'out-in' },
|
||||||
|
head: {
|
||||||
|
viewport: 'width=device-width'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appConfig: {
|
||||||
|
umami: {
|
||||||
|
id: '51f4f246-9c2e-4a86-9ffb-7a7967d9013d',
|
||||||
|
host: 'https://cloud.umami.is/',
|
||||||
|
version: 2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routeRules: {
|
||||||
|
'/hdp': { redirect: '/a_history_of_the_domino_problem' },
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
prerender: { crawlLinks: true}
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
payloadExtraction: true
|
||||||
|
}
|
||||||
|
})
|
||||||
17928
package-lock.json
generated
Normal file
27
package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "nuxt-app",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/image": "^1.0.0-rc.1",
|
||||||
|
"@nuxtjs/tailwindcss": "^6.7.0",
|
||||||
|
"@types/node": "^18",
|
||||||
|
"nuxt-headlessui": "^1.1.4",
|
||||||
|
"nuxt-icon": "^0.4.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pinia/nuxt": "^0.4.11",
|
||||||
|
"mongodb": "^7.1.0",
|
||||||
|
"nuxt": "^3.6.0",
|
||||||
|
"nuxt-swiper": "^1.1.0",
|
||||||
|
"nuxt-umami": "^2.4.2",
|
||||||
|
"pinia": "^2.1.3",
|
||||||
|
"sharp": "^0.32.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
346
pages/a_history_of_the_domino_problem.vue
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
<template>
|
||||||
|
<div class="bg-contain bg-no-repeat bg-center rounded-lg m-5 gap-10 bg-[#0A0A19] py-4 text-2xl text-white py-4 mb-10 overflow-hidden" :style="{ backgroundImage: `url(${image})`}">
|
||||||
|
<div class="rounded-lg w-full sticky top-[10px] grid grid-cols-[63%,35%]">
|
||||||
|
<div>
|
||||||
|
<div class="p-5 text-5xl font-bold">a history of the domino problem</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="inline-flex text-2xl ml-4 mb-5">
|
||||||
|
<a href="#about" class="px-3">about</a>
|
||||||
|
<a href="#exhibition" class="px-3">exhibition</a>
|
||||||
|
<a href="#events" class="px-3">events</a>
|
||||||
|
<a href="#participants" class="px-3">participants</a>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex text-2xl ml-4 mb-5">
|
||||||
|
<a href="#media" class="px-3">media</a>
|
||||||
|
<a href="#contributors" class="px-3">contributors</a>
|
||||||
|
<a href="#resources" class="px-3">resources</a>
|
||||||
|
<a href="#contact" class="px-3">contact/press</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
For the Lecture-Concert on 22 Nov 2023, Registration recommended. Sign up <NuxtLink class="text-2xl font-bold" to='https://www.eventbrite.de/e/a-history-of-the-domino-problem-lecture-concert-tickets-707700981687'>HERE</NuxtLink>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!---
|
||||||
|
<Swiper
|
||||||
|
:loop="true"
|
||||||
|
:spaceBetween="30"
|
||||||
|
:centeredSlides="true"
|
||||||
|
:pagination='{
|
||||||
|
clickable: true,
|
||||||
|
renderBullet: function (index, className) {
|
||||||
|
return "<span class=" + className + ">" + ["about", "exhibition", "events", "participants"][index] + "</span>";
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
:navigation="false"
|
||||||
|
:style="{
|
||||||
|
'--swiper-pagination-color': 'rgb(255 255 255)',
|
||||||
|
'--swiper-pagination-bullet-horizontal-gap': '80px',
|
||||||
|
'--swiper-pagination-bullet-inactive-opacity': '0.8',
|
||||||
|
'--swiper-pagination-bullet-size': '0px',
|
||||||
|
'--swiper-pagination-bottom': 'auto',
|
||||||
|
'--swiper-pagination-top': '1rem',
|
||||||
|
'--swiper-pagination-margin-left': '0px',
|
||||||
|
}"
|
||||||
|
:hashNavigation="{
|
||||||
|
watchState: true,
|
||||||
|
}"
|
||||||
|
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation, SwiperHashNavigation]"
|
||||||
|
class="max-w-[95vw]"
|
||||||
|
>
|
||||||
|
--->
|
||||||
|
<Swiper
|
||||||
|
:loop="true"
|
||||||
|
:spaceBetween="30"
|
||||||
|
:centeredSlides="true"
|
||||||
|
:pagination="false"
|
||||||
|
:navigation="false"
|
||||||
|
:hashNavigation="{
|
||||||
|
watchState: true,
|
||||||
|
}"
|
||||||
|
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation, SwiperHashNavigation]"
|
||||||
|
>
|
||||||
|
<SwiperSlide data-hash="about" class="p-10 text-xl overflow-hidden">
|
||||||
|
<span class="swiper-no-swiping">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<p class="mb-5">
|
||||||
|
<span class="italic">a history of the domino problem</span> is a performance-installation that traces the history of an epistemological problem in mathematics about how things that one could never imagine fitting together, actually come together and unify in unexpected ways. The work comprises a set of musical compositions and a kinetic sculpture that sonify and visualize rare tilings (more commonly known as mosaics) constructed from dominoes. The dominoes in these tilings are similar yet slightly different than those used in the popular game of the same name. As opposed to rectangles divided into two regions with numbers between 1 and 6, they are squares where each of the 4 edges is assigned a number (typically represented by a corresponding color or alternatively, pattern) called <NuxtLink to='https://en.wikipedia.org/wiki/Wang_tile'>Wang tiles</NuxtLink>. Like in the game, the rule is that edges of adjacent dominoes in a tiling must match.
|
||||||
|
</p>
|
||||||
|
<p class="mb-5">
|
||||||
|
The tilings sonified and visualized in <span class="italic">a history of the domino problem</span> are rare because there is no systematic way to find them. This is due to the fact that they are <NuxtLink to='https://en.wikipedia.org/wiki/Aperiodic_tiling'><span class="italic">aperiodic</span></NuxtLink>. One can think of an aperiodic tiling as an infinite puzzle with a peculiar characteristic: given unlimited copies of dominoes with a finite set of color/pattern combinations for the edges, on can form a tiling that expands infinitely. However, in that solution, any repeating structure in the tiling will eventually be interrupted. This phenomenon is one of the most intriguing aspects of the work. As the music and the visuals are derived from the tilings, the resulting textures are always shifting ever so slightly.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The original Domino Problem asked if there exists an algorithm/computer program that, when given as input a finite set of dominoes with varying color combinations for the edges, can output a binary answer, `yes' or `no', whether or not copies of that set can form an infinite tiling. The problem was first posed by Hao Wang in 1961, who conjectured that the answer is positive and that such an algorithm does exist. The existence of aperiodic tilings would mean that such an algorithm <span class="italic">does not</span> exist. However, in 1964, his student, Robert Berger, proved him wrong by discovering an infinite, aperiodic tiling constructed with copies of a set of 20,426 dominoes. The resolution of Wang's original question led to new questions and mathematicians took on the challenge of finding the smallest set of dominoes that would construct an infinite aperiodic tiling. Over the past 60 years, this number has been continually reduced with the contributions of many different mathematicians until the most recent discovery of a set of 11 dominoes along with a proof that no smaller sets exist. It is a remarkable narrative/history of a particular epistemological problem that challenged a group of people not only to solve it, but to understand it to the extent possible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</SwiperSlide>
|
||||||
|
<SwiperSlide data-hash="exhibition" class="p-10 text-xl overflow-hidden">
|
||||||
|
<span class="swiper-no-swiping">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<div class="mb-5 text-3xl italic font-bold">
|
||||||
|
a few thoughts on how things fit together...
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
(free entrance)
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="mb-5">
|
||||||
|
in collaboration with MAREIKE YIN-YEE LEE
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Exhibition Opening - 17 Nov 2023 | 19 Uhr
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Exhibition Closing - 01 Dec 2023 | 19 Uhr
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Gallery Hours - Wednesday to Friday | 13 - 19 Uhr & Saturday | 12 - 18 Uhr
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Lichthof Ost, HU Berlin Hauptgebäude, Campus Mitte, Unter den Linden 6 (U-Bahn Unter den Linden oder Museuminsel)
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<NuxtLink class="px-3" to='/pubs/a_few_thoughts_exhibition_poster.pdf'><nuxt-img class="w-[500px]" src="/hdp_images/hdp_exhibition_poster_digital.jpeg"/></NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<NuxtLink class="px-3" to='/hdp_images/lichthof_ost_map.jpeg'><nuxt-img class="w-[500px]" src="/hdp_images/lichthof_ost_map.jpeg"/></NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<span class="font-bold">About the Exhibition</span>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
The exhibition will feature individual and collaborative works by Michael Winter and Mareike Yin-Yee Lee in a constellation designed specifically for the Lichthof Ost exhibition room of the Humboldt University. The original kinetic sculpture Winter created to visualize the aperiodic tilings of the history of the domino problem will be juxtaposed with recent works by Yin-Yee Lee as well as collaboratively created realizations of the tilings. The works on display by Yin-Yee Lee will highlight selections from her Hidden Lakes and Missing Pieces series in which enigmatic outlines of lakes and various shapes encourage observers to perceive similarities and differences in form, pattern, and repetition between the pieces and to mentally fill in blank space. The collaborative realizations of the tilings will include prints generated by Winter with the aid of a computer that incorporate images and color schemes by Yin-Yee Lee as well as a floor mosaic of drawings on mirrors. The exhibition plays on the macro versus the micro, transformation, and how topologies of various color combinations, relationships between shapes and gradients reflect in space in order to illuminate "a few thoughts on how things fit together..."
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="mb-5">
|
||||||
|
<span class="font-bold">About the Kinetic Sculpture</span>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
The kinetic sculpture displays the mosaics using visual cryptography. In visual cryptography, a message is encrypted by dividing the information of the message into two `shadow' images, which look completely random and independent of each other. The message is decrypted and revealed when the shadow images are combined/overlayed in a precise orientation. In the kinetic sculpture of a history of the domino problem, the shadow images are printed on photomasks, which are essentially high-resolution transparencies: quartz wafers with a chrome coating etched at a pixel size ranging from nano- to micrometers. A high-precision, motorized multiaxis stage aligns the finely printed shadow images to reveal the mosaics (along with 3 other images of poetic texts inspired by the history of the Domino Problem).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</SwiperSlide>
|
||||||
|
<SwiperSlide data-hash="events" class="p-10 text-xl overflow-hidden">
|
||||||
|
<span class="swiper-no-swiping">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<div class="mb-5">
|
||||||
|
<span class="font-bold">Exhibition Opening - 17 Nov 2023 | 19 Uhr</span>
|
||||||
|
<br>
|
||||||
|
Lichthof Ost, HU Berlin Hauptgebäude, Campus Mitte, Unter den Linden 6 (U-Bahn Unter den Linden oder Museuminsel)
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<span class="font-bold">Exhibition Closing - 01 Dec 2023 | 19 Uhr</span>
|
||||||
|
<br>
|
||||||
|
Lichthof Ost, HU Berlin Hauptgebäude, Campus Mitte, Unter den Linden 6 (U-Bahn Unter den Linden oder Museuminsel)
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<span class="font-bold">Public lecture + Concert (free entrance) - 22 Nov 2023 | 19:30 Uhr (doors open at 19:00 Uhr)</span>
|
||||||
|
<br>
|
||||||
|
with Prof. JARKKO KARI (Turku University), moderated by Prof. Dr. GAËTAN BOROT (HU Berlin)
|
||||||
|
<br>
|
||||||
|
the abstract of Prof. JARKKO KARI's Lecture is provided below
|
||||||
|
<br>
|
||||||
|
performance by KALI ENSEMBLE
|
||||||
|
<br>
|
||||||
|
Fritz-Reuter-Saal, HU Berlin Universitätsgebäude (am Hegelplatz), Dorotheenstraße 24 (U-Bahn Unter den Linden oder Museuminsel)
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<span class="font-bold">Concert - 23 Nov 2023 | 20:30 Uhr (doors open at 20:00 Uhr)</span>
|
||||||
|
<br>
|
||||||
|
performance by KALI ENSEMBLE
|
||||||
|
<br>
|
||||||
|
<NuxtLink to='https://www.km28.de/'>KM28</NuxtLink>
|
||||||
|
<br>
|
||||||
|
Karl-Marx-Str. 28, 12043 Berlin (U-Bahn Karl-Marx-Platz)
|
||||||
|
<br>
|
||||||
|
(entry by donation, with proceeds going to the Kali Ensemble)
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="mb-5">
|
||||||
|
<br>
|
||||||
|
About the Public lecture
|
||||||
|
<br>
|
||||||
|
<span class="font-bold">From Wang Tiles to the Domino Problem: A Tale of Aperiodicity</span>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
This presentation delves into the remarkable history of aperiodic tilings and the domino problem. Aperiodic tile sets refer to collections of tiles that can only tile the plane in a non-repeating, or non-periodic, manner. Such sets were not believed to exist until 1964 when R. Berger introduced the first aperiodic set consisting of an astonishing 20,426 Wang tiles. Over the years, ongoing research led to significant advancements, culminating in 2015 with the discovery of a mere 11 Wang tiles by E. Jeandel and M. Rao, alongside a computer-assisted proof of their minimality. Simultaneously, researchers found even smaller aperiodic sets composed of polygon-shaped tiles. Notably, Penrose's kite and dart tiles emerged as early examples, and most recently, a groundbreaking discovery was made - a solitary aperiodic tile known as the "hat" that can tile the plane exclusively in a non-periodic manner. Aperiodic tile sets are intimately connected with the domino problem that asserts how certain tile sets can tile the plane without us ever being able to establish their tiling nature with absolute certainty. Moreover, aperiodic tilings hold a distinct visual aesthetic allure. In today's musical presentation, their artistic appeal transcends the visual domain and extends into the realm of music.
|
||||||
|
<br>
|
||||||
|
-Jarkko Kari
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</SwiperSlide>
|
||||||
|
<SwiperSlide data-hash="participants" class="p-10 text-xl overflow-hidden">
|
||||||
|
<span class="swiper-no-swiping">
|
||||||
|
<div class="max-h-[800px] overflow-auto">
|
||||||
|
<div class="mb-5 py-10">
|
||||||
|
<NuxtLink class="text-3xl font-bold" to='/'>Michael Winter - composer | sound artist</NuxtLink>
|
||||||
|
<div class="grid grid-cols-[20%,70%] p-5">
|
||||||
|
<nuxt-img src="/hdp_images/michael.jpg"/>
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="mb-5">
|
||||||
|
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 phenomenology, mathematics, epistemology, algorithmic information theory, and the history of science. To me, everything we experience is computable. 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 considering and addressing the limits of computation from an artistic and experiential vantage point and by collaborating with other artists, mathematicians, and scientists in order to integrate objects, ideas, and texts from various domains as structural elements in my pieces. My work also aims to subvert discriminatory conventions and hierarchies by exploring alternative forms of presentation and interaction, often with minimal resources and low information.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
My work has 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 the wulf., a Los Angeles-based organization dedicated to experimental performance and art. From 2018 to 2019, I was a fellow / artist-in-residence at the Akademie Schloss Solitude in Stuttgart, Germany. I currently reside in Berlin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 py-10">
|
||||||
|
<NuxtLink class="text-3xl font-bold" to='http://www.mareikelee.com/'>MAREIKE YIN-YEE LEE - visual artist</NuxtLink>
|
||||||
|
<div class="grid grid-cols-[20%,70%] p-5">
|
||||||
|
<nuxt-img src="/hdp_images/mareike.jpg"/>
|
||||||
|
<div class="px-5">
|
||||||
|
Mareike Yin‑Yee Lee’s multidisciplinary practice encompasses drawing, video, sculpture, found and made objects, printmaking, and artist books. Current works include installations, recordings and live performances produced in collaboration with musicians and composers with an emphasis on the relation between sight and sound. How we approach, perceive and respond to these form the basis of her recent works‘ manifestations. Her immersive, site-specific installations explore the complex and tenuous nature of communication and how we experience space, drawing on gesture, sound, and memory to elicit responses that cannot be put into words. She redefines the architecture and temporality of the spaces in which she works. Lee’s work plays with the spaces between, across, and beyond, embracing the undefinable and subtle gradations, forging a language of colour, tone and space that seeks to articulate microcosms of daily life and sustained contemplation. Lee studied at Universität der Künste, Berlin, Germany; University of Toronto, Toronto, Canada; and Nova Scotia College of Art and Design, Nova Scotia, Canada, where she was awarded the Joseph Beuys Scholarship and the Canada Millennium Award of Excellence. Recent projects include exhibitions and performances at Kunsthaus Kule Berlin (2020), Kunstmuseum Kloster Unser Lieben Frauen Magdeburg (2019), Galerie Kunstpunkt Berlin (2018), Kunstbezirk Stuttgart, Kunst(zeug)haus Rapperswil- Jona Switzerland (2017), Kunsthaus Interlaken (2017), Neuer Kunstverein, Aschaffenburg (2016), and KW Institute for Contemporary Art, Berlin (2016).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 py-10">
|
||||||
|
<NuxtLink class="text-3xl font-bold" to='https://www.facebook.com/KaliEnsemble'>KALI - performing ensemble</NuxtLink>
|
||||||
|
<div class="grid grid-cols-[20%,70%] p-5">
|
||||||
|
<nuxt-img src="/hdp_images/kali.jpg"/>
|
||||||
|
<div class="px-5">
|
||||||
|
Kali is a new music ensemble based in the Hague. They primarily work with composers with whom they can collaborate and experiment over long periods. They aim to develop an artistic practice unique to their relationship with their collaborators. Over the past years, they have formed close and active relationships with several composers based in The Hague and abroad realizing many large-scale projects with great attention to detail.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 py-10">
|
||||||
|
<NuxtLink class="text-3xl font-bold" to='https://users.utu.fi/jkari/'>Jarkko Kari - mathematician | invited guest</NuxtLink>
|
||||||
|
<div class="grid grid-cols-[20%,70%] p-5">
|
||||||
|
<nuxt-img class="w-full" src="/hdp_images/jarkko.jpg"/>
|
||||||
|
<div class="px-5">
|
||||||
|
Jarkko Kari received his MSc and PhD degrees in mathematics from the University of Turku in Finland in 1986 and 1990, respectively. He then worked for the Academy of Finland, and for Iterated Systems Inc. and the University of Iowa in the USA. Since year 2000 he has been a professor of mathematics at the University of Turku. His research interests include automata theory and the theory of computation, with emphasis on cellular automata, tilings and symbolic dynamics. Jarkko Kari has supervised twelve PhD theses, published over one hundred peer reviewed research articles and edited twenty conference proceedings and special issues on these topics. He serves in the editorial boards of eight scientific journals, and is currently a co-editor-in-chief of the journal Natural Computing. Jarkko Kari is a member of the Finnish Academy of Science and Letters since 2014.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 py-10">
|
||||||
|
<NuxtLink class="text-3xl font-bold" to='https://www.mathematik.hu-berlin.de/de/forschung/forschungsgebiete/mathematische-physik/borot-mp-homepage'>Gaëtan Borot - mathematician | organizer | moderator</NuxtLink>
|
||||||
|
<div class="grid grid-cols-[20%,70%] p-5">
|
||||||
|
<nuxt-img class="w-full" src="/hdp_images/gaetan.jpg"/>
|
||||||
|
<div class="px-5">
|
||||||
|
Gaëtan Borot was trained at École Normale Supérieure (Paris) in theoretical physicist and progressively moved to pure mathematics. He received his PhD from Universite d'Orsay / CEA Saclay in 2011. After a postdoctorate in Geneva and a visiting scholarship at MIT, he worked as a Group Leader at the Max Planck Institute for Mathematics in Bonn. Since 2020, he holds a bridge professorship between the Institute of Mathematics and the Institute of Physics of the Humboldt University of Berlin. He has worked on enumerative geometry, combinatorics, random matrix theory and mathematical aspects of quantum field theory, and likes to investigate the unexpected relations between seemingly different problems. He is also interested in scientific outreach.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</SwiperSlide>
|
||||||
|
<SwiperSlide data-hash="media" class="p-20 text-xl overflow-hidden">
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<iframe src="https://player.vimeo.com/video/375784136" width="640" height="360" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen ></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</SwiperSlide>
|
||||||
|
<SwiperSlide data-hash="contributors" class="p-10 text-xl overflow-hidden">
|
||||||
|
<div class="max-h-[calc(100vh-27rem)] overflow-auto">
|
||||||
|
<div class="grid grid-cols-5 p-5 items-center">
|
||||||
|
<NuxtLink class="px-3" to='https://www.hu-berlin.de/en'><nuxt-img class="w-[100px]" src="/hdp_images/hu_logo.png"/></NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='https://www.km28.de/'><nuxt-img class="w-[100px]" src="/hdp_images/km28_logo.png"/></NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='https://www.ims-chips.com/'><nuxt-img class="w-[250px]" src="/hdp_images/ims_chips_logo.png"/></NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='https://www.akademie-solitude.de/'><nuxt-img class="w-[100px]" src="/hdp_images/akademie_schloss_solitude_logo.png"/></NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='https://mathplus.de/'><nuxt-img class="w-[200px]" src="/hdp_images/mathplus_logo_gray.png"/></NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
<SwiperSlide data-hash="resources" class="p-10 text-xl overflow-hidden">
|
||||||
|
<span class="swiper-no-swiping">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
|
||||||
|
<div class="mb-5 text-2xl font-bold">
|
||||||
|
a few selected articles:
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Hao Wang (1961), Proving theorems by pattern recognition—II, Bell System Technical Journal, Volume: 40, Issue: 1.
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Robert Berger (1966), The undecidability of the domino problem, American Mathematical Society, Volume 1, 1966.
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Jarkko Kari (1996), A small aperiodic set of Wang tiles, Discrete Mathematics, Volume 160.
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Emmanuel Jeandel and Michael Rao, An aperiodic set of 11 Wang tiles, Advances in Combinatorics, Volume 1.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="mb-5 text-2xl font-bold">
|
||||||
|
a definitive book on tilings and patterns:
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
Branko Grunbaum and G.C. Shephard, Tilings and Patterns, Dover Books (originally published 1986)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="mb-5 text-2xl font-bold">
|
||||||
|
a few useful links:
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<NuxtLink to='https://grahamshawcross.com/2012/10/12/aperiodic-tiling/'>https://grahamshawcross.com/2012/10/12/aperiodic-tiling/</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<NuxtLink to='https://grahamshawcross.com/2012/10/12/wang-tiles-and-aperiodic-tiling/'>https://grahamshawcross.com/2012/10/12/wang-tiles-and-aperiodic-tiling/</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<NuxtLink to='https://en.wikipedia.org/wiki/Wang_tile'>https://en.wikipedia.org/wiki/Wang_tile</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<NuxtLink to='https://en.wikipedia.org/wiki/Aperiodic_tiling'>https://en.wikipedia.org/wiki/Aperiodic_tiling</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</SwiperSlide>
|
||||||
|
<SwiperSlide data-hash="contact" class="p-10 text-xl overflow-hidden">
|
||||||
|
<span class="swiper-no-swiping">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
|
||||||
|
<div class="mb-5 text-2xl">
|
||||||
|
For information or any inquiries email Michael Winter by clicking
|
||||||
|
<NuxtLink class="inline-flex p-1" to='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'>
|
||||||
|
<span class="font-bold">HERE</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 text-2xl">
|
||||||
|
To download the poster for the project, click
|
||||||
|
<NuxtLink class="inline-flex p-1" to='/pubs/hdp_poster.pdf'>
|
||||||
|
<span class="font-bold">HERE</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 text-2xl">
|
||||||
|
To download the poster specifically for the exhibition, click
|
||||||
|
<NuxtLink class="inline-flex p-1" to='/pubs/a_few_thoughts_exhibition_poster.pdf'>
|
||||||
|
<span class="font-bold">HERE</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</SwiperSlide>
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import hdp_background from "assets/hdp_background.png";
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
image: hdp_background, };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
audioPlayerStore.setSoundCloudTrackID(324252345)
|
||||||
|
|
||||||
|
</script>
|
||||||
91
pages/about.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<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">
|
||||||
|
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">
|
||||||
|
My music and installations have been presented at venues and festivals throughout the world such as REDCAT, in Los Angeles; the Ostrava Festival of New Music in the Czech Republic; Tsonami Arte Sonoro Festival in Valparaiso, Chile; the Huddersfield New Music Festival in the United Kingdom; and Umbral Sesiones at the Museo de Arte Contemporáneo in Oaxaca, Mexico. Recordings of my music have been released by XI Records, Another Timbre, New World Records, Edition Wandelweiser, Bahn Mi Verlag, Tsonami Records, and Pogus Productions. In 2008, I co-founded <em>the wulf.</em>, a Los Angeles-based organization dedicated to experimental performance and art that presented over 350 events in 8 years. From 2018 to 2019, I was a fellow / artist-in-residence at the Akademie Schloss Solitude in Stuttgart, Germany. I currently teach as University Professor of Sound and Intermedia at the Gustav Mahler Privatuniversität für Musik in Klagenfurt, Austria while maintaining my primary residence in Berlin, Germany.
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<div id="mc_embed_signup">
|
||||||
|
<form action="https://unboundedpress.us12.list-manage.com/subscribe/post?u=bdadd25738fedf704641f3a80&id=01c5761ebb&f_id=00f143e0f0" method="post" id="mc-embedded-subscribe-form" name="mc-embedded-subscribe-form" class="validate" target="_self">
|
||||||
|
<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">
|
||||||
|
<div style="position: absolute; left: -5000px;" aria-hidden="true">
|
||||||
|
<input type="text" name="b_bdadd25738fedf704641f3a80_01c5761ebb" tabindex="-1" value="">
|
||||||
|
</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"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="inline-flex place-items-center p-2">
|
||||||
|
CV
|
||||||
|
<div>
|
||||||
|
<IconButton :visible="true" type="document" work="placeholder" link="/cv" :newTab="true"></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" :newTab="true"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-5">
|
||||||
|
<ImageSlider bucket="images" :gallery="gallery" class="max-w-[90%]"></ImageSlider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
|
||||||
|
const { data: gallery } = await useFetch('/api/my_image_gallery')
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - About - Short Bio, Contact, CV, Works List, and Mailing List'
|
||||||
|
})
|
||||||
|
</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; }
|
||||||
|
}
|
||||||
|
#mc_embed_signup{clear:left; width:100%;}
|
||||||
|
</style>
|
||||||
424
pages/cv.vue
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'plain'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: resumeData } = await useFetch('/api/resume')
|
||||||
|
const { data: talksData } = await useFetch('/api/talks')
|
||||||
|
const resume = computed(() => resumeData.value?.[0])
|
||||||
|
|
||||||
|
const talksByYear = computed(() => {
|
||||||
|
if (!talksData.value) return []
|
||||||
|
|
||||||
|
const byYear = {}
|
||||||
|
for (const talk of talksData.value) {
|
||||||
|
const year = talk.date ? new 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]
|
||||||
|
|
||||||
|
const byLocation = {}
|
||||||
|
for (const talk of talks) {
|
||||||
|
const key = `${talk.location}|||${talk.date}`
|
||||||
|
if (!byLocation[key]) byLocation[key] = []
|
||||||
|
byLocation[key].push(talk)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = Object.values(byLocation).map(group => ({
|
||||||
|
location: group[0].location,
|
||||||
|
date: group[0].date,
|
||||||
|
titles: group.map(t => t.title)
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { year, groups }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatMonth(dateStr) {
|
||||||
|
if (!dateStr) return 'Present'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date)) return dateStr
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYear(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date)) return dateStr
|
||||||
|
return date.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cv-container">
|
||||||
|
<header class="cv-header">
|
||||||
|
<h1>Michael Winter</h1>
|
||||||
|
<h3>Curriculum Vitae</h3>
|
||||||
|
<p class="contact">
|
||||||
|
{{ resume?.basics?.email }} · {{ resume?.basics?.phone }} · {{ resume?.basics?.website }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<!-- Education -->
|
||||||
|
<section v-if="resume?.education?.length" class="cv-section">
|
||||||
|
<h4>Education</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="edu in resume.education" :key="edu.id" class="item">
|
||||||
|
<span class="item-title">{{ edu.studyType }} in {{ edu.area }}</span>
|
||||||
|
<span class="item-meta">{{ edu.institution }}, {{ formatYear(edu.endDate) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Teaching -->
|
||||||
|
<section v-if="resume?.teaching?.length" class="cv-section">
|
||||||
|
<h4>Teaching</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="teach in resume.teaching" :key="teach.id" class="item">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-title">{{ teach.company }}</span>
|
||||||
|
<span class="item-subtitle">{{ teach.position }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">{{ formatMonth(teach.startDate) }} – {{ formatMonth(teach.endDate) }}</div>
|
||||||
|
<ul v-if="teach.highlights" class="item-list">
|
||||||
|
<li v-for="h in teach.highlights">{{ h }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Lectures -->
|
||||||
|
<section v-if="talksByYear.length" class="cv-section">
|
||||||
|
<h4>Lectures</h4>
|
||||||
|
<div v-for="yearGroup in talksByYear" :key="yearGroup.year" class="year-group">
|
||||||
|
<div class="year-header">{{ yearGroup.year }}</div>
|
||||||
|
<div v-for="(group, idx) in yearGroup.groups" :key="idx" class="item">
|
||||||
|
<span class="item-title">{{ group.location }}</span>
|
||||||
|
<template v-for="(title, tidx) in group.titles" :key="tidx">
|
||||||
|
<div class="item-detail talk-title" v-if="Array.isArray(title)">
|
||||||
|
<em v-for="(t, i) in title" :key="i" style="display: block;">{{ t }}</em>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail talk-title" v-else>
|
||||||
|
<em style="display: block;">{{ title }}</em>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Relevant Work -->
|
||||||
|
<section v-if="resume?.work?.length" class="cv-section">
|
||||||
|
<h4>Relevant Work</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="w in resume.work" :key="w.id" class="item">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-title">{{ w.company }}</span>
|
||||||
|
<span class="item-subtitle">{{ w.position }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">{{ formatMonth(w.startDate) }} – {{ formatMonth(w.endDate) }}</div>
|
||||||
|
<ul v-if="w.highlights" class="item-list">
|
||||||
|
<li v-for="h in w.highlights">{{ h }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Skills -->
|
||||||
|
<section v-if="resume?.skills?.length" class="cv-section">
|
||||||
|
<h4>Coding Skills</h4>
|
||||||
|
<p class="item-detail">
|
||||||
|
<span v-for="(skill, idx) in resume.skills" :key="skill.id">
|
||||||
|
{{ skill.keywords?.join(', ') }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Languages -->
|
||||||
|
<section v-if="resume?.languages?.length" class="cv-section">
|
||||||
|
<h4>Language Skills</h4>
|
||||||
|
<p class="item-detail">
|
||||||
|
<span v-for="(lang, idx) in resume.languages" :key="lang.id">
|
||||||
|
{{ lang.language }} — {{ lang.fluency }}{{ idx < resume.languages.length - 1 ? '; ' : '' }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Publications -->
|
||||||
|
<section v-if="resume?.publications?.length" class="cv-section">
|
||||||
|
<h4>Publications</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="pub in resume.publications" :key="pub.id" class="item">
|
||||||
|
<div class="item-title" v-html="pub.entryTags?.title"></div>
|
||||||
|
<div class="bib">
|
||||||
|
{{ pub.entryTags?.author }}
|
||||||
|
<span v-if="pub.entryTags?.editor">, editors {{ pub.entryTags.editor }}.</span>
|
||||||
|
<span v-if="pub.entryTags?.booktitle"><em>{{ pub.entryTags.booktitle }}.</em></span>
|
||||||
|
<span v-if="pub.entryTags?.journal"><em>{{ pub.entryTags.journal }}</em>,</span>
|
||||||
|
<span v-if="pub.entryTags?.volume">vol. {{ pub.entryTags.volume }}</span>
|
||||||
|
<span v-if="pub.entryTags?.publisher">{{ pub.entryTags.publisher }},</span> {{ pub.entryTags?.year }}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recordings -->
|
||||||
|
<section v-if="resume?.solo_releases?.length || resume?.compilation_releases?.length" class="cv-section">
|
||||||
|
<h4>Recordings</h4>
|
||||||
|
|
||||||
|
<div v-if="resume?.solo_releases?.length" class="subsection">
|
||||||
|
<div class="subsection-title"><strong>Solo Albums</strong></div>
|
||||||
|
<div v-for="rel in resume.solo_releases" :key="rel.id" class="item recording-item">
|
||||||
|
<span class="item-title">{{ rel.title }}</span>
|
||||||
|
<span class="item-detail">{{ rel.publisher }}. {{ rel.media_type }}. {{ rel.date }}.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resume?.compilation_releases?.length" class="subsection">
|
||||||
|
<div class="subsection-title"><strong>Compilation Albums</strong></div>
|
||||||
|
<div v-for="rel in resume.compilation_releases" :key="rel.id" class="item recording-item">
|
||||||
|
<span class="item-title">{{ rel.title }}</span>
|
||||||
|
<span class="item-detail">{{ rel.publisher }}. {{ rel.media_type }}. {{ rel.date }}.</span>
|
||||||
|
<div class="item-detail">featuring <span class="italic">{{ rel.work }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Residencies -->
|
||||||
|
<section v-if="resume?.residencies?.length" class="cv-section">
|
||||||
|
<h4>Residencies and Awards</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="res in resume.residencies" :key="res.id" class="item">
|
||||||
|
<span class="item-title">{{ res.org }}</span>
|
||||||
|
<span class="item-meta">{{ res.date }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- References -->
|
||||||
|
<section v-if="resume?.references?.length" class="cv-section">
|
||||||
|
<h4>References</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="ref in resume.references" :key="ref.id" class="item">
|
||||||
|
<span class="item-title">{{ ref.name }}</span>
|
||||||
|
<span class="item-detail">{{ ref.position }}</span>
|
||||||
|
<span class="item-detail">{{ ref.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cv-container {
|
||||||
|
font-size: 12px;
|
||||||
|
width: 175mm;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 30px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header .contact {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-entry {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-subtitle {
|
||||||
|
font-style: italic;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-title {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-header {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bib {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #222;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.cv-container {
|
||||||
|
margin: 0;
|
||||||
|
padding: 15mm;
|
||||||
|
width: auto;
|
||||||
|
font-size: 10pt;
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 10pt;
|
||||||
|
border-bottom: 1pt solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta,
|
||||||
|
.item-detail,
|
||||||
|
.item-list {
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-header {
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top: 1pt solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-entry,
|
||||||
|
.year-group,
|
||||||
|
.subsection {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
pages/events.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<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-for="(instrument, index) in performer.instrument_tags">
|
||||||
|
<span v-if="index !== 0">, </span>
|
||||||
|
{{ instrument }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="italic text-sm ml-16 pt-1">{{item.legacy_program}}</div>
|
||||||
|
<div class="ml-20">{{item.legacy_performers}}</div>
|
||||||
|
</template>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">lectures</p>
|
||||||
|
|
||||||
|
<div class="leading-tight py-2 ml-3 text-sm" v-for="item in lectures">
|
||||||
|
<div class="gap-1">
|
||||||
|
<div>
|
||||||
|
{{ item.formatted_date }}: {{item.location}}
|
||||||
|
<div v-for="talk in item.talks" class="ml-4 text-[#7F7F7F]">
|
||||||
|
{{ talk.title }}
|
||||||
|
</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.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
||||||
|
}
|
||||||
|
return events.sort((a,b) => new Date(b.start_date) - new Date(a.start_date))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: lectures } = 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) + "." + ("0" + date.getDate()).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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Events - Performances and Lectures'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
151
pages/index.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<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="font-thin">{{ 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 font-thin">
|
||||||
|
<div class="italic text-sm">{{ work.title }}</div>
|
||||||
|
<div class="inline-flex">
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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"></IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5">
|
||||||
|
<p class="text-lg">albums</p>
|
||||||
|
<div class="leading-tight py-4 ml-3 text-sm" v-for="item in releases">
|
||||||
|
<p class="text-center 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"></IconButton>
|
||||||
|
<IconButton :visible="item.buy_link" type="buy" :link="item.buy_link"></IconButton>
|
||||||
|
</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')
|
||||||
|
|
||||||
|
const { data: works } = await useFetch('/api/works', {
|
||||||
|
transform: (works) => {
|
||||||
|
for (const work of works) {
|
||||||
|
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(works, 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', {
|
||||||
|
transform: (pubs) => {
|
||||||
|
for (const pub of pubs) {
|
||||||
|
if(pub.entryTags && pub.entryTags.howpublished && !(isValidUrl(pub.entryTags.howpublished))){
|
||||||
|
pub.entryTags.howpublished = "/pubs/" + pub.entryTags.howpublished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pubs.sort((a,b) => (a.citationKey > b.citationKey) ? -1 : ((b.citationKey > a.citationKey) ? 1 : 0))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: releases } = await useFetch('/api/releases', {
|
||||||
|
transform: (releases) => {
|
||||||
|
return releases.sort((a,b) => {
|
||||||
|
const dateA = parseInt(a.date) || 0
|
||||||
|
const dateB = parseInt(b.date) || 0
|
||||||
|
return dateB - dateA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Home / Works - Pieces, Publications, and Albums'
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
28
pages/scores/[filename].vue
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-full items-center justify-center text-center">
|
||||||
|
<embed v-if="isPdf" :src="filePath" class="w-[85%] h-[88vh]"/>
|
||||||
|
<img v-else-if="isImage" :src="filePath" class="w-[85%]"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const filePath = computed(() => {
|
||||||
|
const filename = route.params.filename
|
||||||
|
return '/scores/' + filename
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPdf = computed(() => {
|
||||||
|
return route.params.filename?.endsWith('.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isImage = computed(() => {
|
||||||
|
const fn = route.params.filename || ''
|
||||||
|
return fn.endsWith('.jpg') || fn.endsWith('.jpeg') || fn.endsWith('.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Scores - ' + route.params.filename
|
||||||
|
})
|
||||||
|
</script>
|
||||||
228
pages/works_list.vue
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'plain'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: resume } = await useFetch('/api/resume')
|
||||||
|
const { data: works } = await useFetch('/api/works')
|
||||||
|
const { data: events } = await useFetch('/api/events')
|
||||||
|
|
||||||
|
const worksByYear = computed(() => {
|
||||||
|
if (!works.value) return []
|
||||||
|
|
||||||
|
const grouped = {}
|
||||||
|
|
||||||
|
for (const work of works.value) {
|
||||||
|
const year = work.date ? new Date(work.date).getFullYear() : 'Unknown'
|
||||||
|
if (!grouped[year]) {
|
||||||
|
grouped[year] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const workEvents = events.value?.filter(e => {
|
||||||
|
if (!e.program) return false
|
||||||
|
return e.program.some(p => p.work?.toLowerCase().includes(work.title.toLowerCase()))
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
grouped[year].push({
|
||||||
|
...work,
|
||||||
|
location: work.instrument_tags?.[0] || '',
|
||||||
|
events: workEvents
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(grouped)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.map(year => ({
|
||||||
|
year,
|
||||||
|
works: grouped[year].sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date)) return dateStr
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cv-container">
|
||||||
|
<header class="cv-header">
|
||||||
|
<h1>{{ resume?.[0]?.basics?.name }}</h1>
|
||||||
|
<h3>Works List with Presentation History</h3>
|
||||||
|
<p class="contact">
|
||||||
|
{{ resume?.[0]?.basics?.email }} · {{ resume?.[0]?.basics?.phone }} · {{ resume?.[0]?.basics?.website }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<p class="intro">
|
||||||
|
A chronological performance / exhibition history, scores, and recordings are available at<br>
|
||||||
|
www.unboundedpress.org.<br>
|
||||||
|
All scores are also published or forthcoming through Frog Peak at<br>
|
||||||
|
www.frogpeak.org/fpartists/fpwinter.html.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Works by Year -->
|
||||||
|
<section v-for="yearGroup in worksByYear" :key="yearGroup.year" class="cv-section">
|
||||||
|
<h4>{{ yearGroup.year }}</h4>
|
||||||
|
|
||||||
|
<div v-for="work in yearGroup.works" :key="work.id" class="work-entry">
|
||||||
|
<div class="work-title"><em>{{ work.title }}</em></div>
|
||||||
|
<div class="work-info" v-if="work.instrument_tags">
|
||||||
|
<span v-for="(tag, idx) in work.instrument_tags" :key="tag">
|
||||||
|
{{ tag }}{{ idx < work.instrument_tags.length - 1 ? ', ' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="work-events" v-if="work.events?.length">
|
||||||
|
<div v-for="event in work.events" :key="event._id?.$oid || event.id" class="event">
|
||||||
|
{{ event.venue?.name }}; {{ event.venue?.city }}, {{ event.venue?.state }} — {{ formatDate(event.start_date) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cv-container {
|
||||||
|
font-size: 12px;
|
||||||
|
width: 175mm;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 30px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header .contact {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-title {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-info {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-events {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
padding-left: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.cv-container {
|
||||||
|
margin: 0;
|
||||||
|
padding: 15mm;
|
||||||
|
width: auto;
|
||||||
|
font-size: 10pt;
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 10pt;
|
||||||
|
border-bottom: 1pt solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-title {
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-info,
|
||||||
|
.work-events {
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top: 1pt solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry,
|
||||||
|
.cv-section {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
public/album_art/CiCC_cover.jpg
Normal file
|
After Width: | Height: | Size: 832 KiB |
BIN
public/album_art/DIY_Canons_cover.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/album_art/Ostrava_cover.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/album_art/Rounds_cover.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
public/album_art/approximating_omega_cover.jpg
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
public/album_art/lower_limit_cover.jpg
Normal file
|
After Width: | Height: | Size: 491 KiB |
BIN
public/album_art/preliminary_thoughts_BMV.jpg
Normal file
|
After Width: | Height: | Size: 874 KiB |
BIN
public/album_art/preliminary_thoughts_TR.jpg
Normal file
|
After Width: | Height: | Size: 1,010 KiB |
BIN
public/album_art/single_track_cover.jpg
Normal file
|
After Width: | Height: | Size: 569 KiB |
BIN
public/album_art/thumb_DIY_Canons_cover.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/album_art/thumb_Ostrava_cover.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/album_art/thumb_Rounds_cover.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/album_art/thumb_approximating_omega_cover.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/album_art/thumb_lower_limit.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/album_art/thumb_preliminary_thoughts_BMV.jpg
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
public/album_art/thumb_preliminary_thoughts_TR.jpg
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
public/album_art/thumb_west_coast_soundings_cover.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/album_art/west_coast_soundings_cover.jpg
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/hdp_images/akademie_schloss_solitude_logo.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/hdp_images/gaetan.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/hdp_images/hdp_exhibition_poster_digital.jpeg
Normal file
|
After Width: | Height: | Size: 367 KiB |
BIN
public/hdp_images/hu_logo.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/hdp_images/ims_chips_logo.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/hdp_images/jarkko.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
public/hdp_images/kali.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/hdp_images/km28_logo.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/hdp_images/lichthof_ost_map.jpeg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/hdp_images/mareike.jpg
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
public/hdp_images/mathplus_logo_gray.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/hdp_images/michael.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/images/for_gregory_chaitin.jpg
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
public/images/mbw_domino_perf.jpg
Normal file
|
After Width: | Height: | Size: 1,003 KiB |
BIN
public/images/mbw_hundred_years_1.jpg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/mbw_hundred_years_2.jpg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
public/images/mbw_oaxaca_1.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/mbw_oaxaca_2.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/mbw_oaxaca_3.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/images/mbw_ostrava_1.jpg
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
public/images/mbw_plants_foto.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/images/minor_third_abstract.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/images/quieting_rooms_image_1.jpg
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
BIN
public/images/quieting_rooms_image_2.jpg
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
BIN
public/images/quieting_rooms_image_3.jpg
Normal file
|
After Width: | Height: | Size: 4 MiB |
BIN
public/images/quieting_rooms_image_4.jpg
Normal file
|
After Width: | Height: | Size: 3 MiB |
BIN
public/images/rockfall.jpg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/thumb_for_gregory_chaitin.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/images/thumb_mbw_domino_perf.jpg
Normal file
|
After Width: | Height: | Size: 882 KiB |
BIN
public/images/thumb_mbw_hundred_years_1.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/images/thumb_mbw_hundred_years_2.jpg
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
public/images/thumb_mbw_oaxaca_1.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/images/thumb_mbw_oaxaca_2.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/images/thumb_mbw_oaxaca_3.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/images/thumb_mbw_ostrava_1.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
public/images/thumb_mbw_plants_foto.jpg
Normal file
|
After Width: | Height: | Size: 637 KiB |
BIN
public/images/thumb_minor_third_abstract.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/images/thumb_quieting_rooms_image_1.jpg
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
public/images/thumb_quieting_rooms_image_2.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
public/images/thumb_quieting_rooms_image_3.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
public/images/thumb_quieting_rooms_image_4.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/images/thumb_rockfall.jpg
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
public/images/thumb_waterline_la_river_1.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
public/images/thumb_waterline_la_river_2.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/images/thumb_waterline_ostrava.jpg
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
public/images/waterline_la_river_1.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/images/waterline_la_river_2.jpg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/images/waterline_ostrava.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |