convert frontend to workspace

This commit is contained in:
lightling 2024-05-02 17:41:51 -04:00
parent 7f03e9ef13
commit c223f2f7af
42 changed files with 108 additions and 64 deletions

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,36 @@
{
"name": "static-web-templates",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "vite",
"build": "vue-tsc && vite-ssg build",
"preview": "vite preview"
},
"devDependencies": {
"@types/dompurify": "3.0.5",
"@types/js-yaml": "4.0.9",
"@types/node": "18.16.x",
"@unhead/vue": "1.8.12",
"@vitejs/plugin-vue": "5.0.4",
"dompurify": "3.0.9",
"github-slugger": "2.0.0",
"highlight.js": "11.9.0",
"js-yaml": "4.1.0",
"marked": "12.0.1",
"marked-highlight": "2.1.1",
"normalize.css": "8.0.1",
"pinia": "2.1.7",
"pug": "3.0.2",
"rfdc": "1.3.1",
"sass": "1.71.1",
"typescript": "5.4.2",
"vite": "5.1.4",
"vite-ssg": "0.23.6",
"vue": "3.4.19",
"vue-router": "4.3.0",
"vue-tippy": "6.4.1",
"vue-tsc": "1.8.27"
}
}

View file

@ -0,0 +1,78 @@
import type { ProjectListingInfo } from 'src/views/project/project'
import { getFormattedPeriod } from 'src/utilities/dom'
/**
* Queries for description list nodes and inflates them with extra functionality
* @param _window the reference to the window
*/
export const inflateDescriptionListEmbeds = (_projectInfo?: ProjectListingInfo) => {
document.querySelectorAll('dl:not(.no-inflate)').forEach((_element) => {
new DescriptionListElement(_element as HTMLDListElement, _projectInfo)
})
}
/**
* Inflates description list elements with extra functionality, namely tags and time period
*/
export class DescriptionListElement {
element: HTMLDListElement
constructor(_element: HTMLDListElement, _projectInfo?: ProjectListingInfo) {
this.element = _element
if (!!_projectInfo) {
this._inflatePeriod(_projectInfo)
}
this._inflateTags()
}
/**
* Searches for the presence of a .period element wrapping around <dt> and <dd>,
* and inflates the inner <dd> element by replacing the first instance of $PERIOD with formatted period
* @param _projectInfo the project info in which the project's time period is defined.
*/
_inflatePeriod = (_projectInfo: ProjectListingInfo) => {
const periodWrapper = this.element.querySelector('.period')
if (!!periodWrapper) {
if (!!_projectInfo.period) {
const periodDescriptionElement = periodWrapper.querySelector('dd')
if (!!periodDescriptionElement) {
periodDescriptionElement.innerHTML = periodDescriptionElement.innerHTML.replace('$PERIOD', getFormattedPeriod(_projectInfo.period))
} else {
console.warn('Found a .period element but not an inner dd element to format on. The .period class should be on a div wrapping around <dt> and <dd>')
}
} else {
console.warn('Found a .period element but the project information is missing a period.')
}
}
}
/**
* Searches for the presence of a .tags element wrapping around <dt> and <dd>,
* and inflates the inner <dd> element by replacing the inner key-value JSON object with an array of span elements,
* where the span's inner text corresponds to the keys of the JSON object,
* and the span's class corresponds to the values of the JSON object.
*/
_inflateTags = () => {
const tagsWrapper = this.element.querySelector('.tags')
if (!!tagsWrapper) {
let originalContent: { [key: string]: string } = { }
try {
const tagsDescriptionElement = tagsWrapper.querySelector('dd')
if (!!tagsDescriptionElement) {
originalContent = JSON.parse(tagsDescriptionElement.innerHTML)
const formattedText = Object.keys(originalContent).map((key) => (
`<span class="tag ${originalContent[key]}">${key}</span>`
)).join('')
tagsDescriptionElement.innerHTML = formattedText
} else {
console.warn('Found a .tags element but not an inner dd element to format on. The .tags class should be on a div wrapping around <dt> and <dd>.')
}
} catch (err) {
console.error('Found a .tags element but the inner dd element was improperly formatted. The <dd> element should contain a valid JSON object.')
console.error(originalContent)
console.error(err)
}
}
}
}

View file

@ -0,0 +1,129 @@
export type DetailsAnimationState =
| 'idle'
| 'opening'
| 'closing'
/**
* Queries for HTMLDetailsElement nodes and inflates them with extra functionality
* @param _window the reference to the window
*/
export const inflateDetailsElements = () => {
const styles = window.getComputedStyle(document.documentElement)
const options: KeyframeAnimationOptions = {
duration: Number(styles.getPropertyValue('--theme-transition-duration')?.replace('s', '') || 0.5) * 1000,
easing: styles.getPropertyValue('--theme-transition-function') || 'ease-in-out',
}
document.querySelectorAll('details:not(.no-inflate)').forEach((_element) => {
new DetailsElement(_element as HTMLDetailsElement, options)
})
}
/**
* Inflates HTMLDetailsElement with extra functionality, namely animations
*/
export class DetailsElement {
animationOptions?: KeyframeAnimationOptions
element: HTMLDetailsElement
summary: HTMLElement
content: HTMLElement
animation: Animation | null = null
animationState: DetailsAnimationState = 'idle'
set _animationState(_value: DetailsAnimationState) {
this.animationState = _value
this.element.classList.remove('opening', 'closing')
if (this.animationState !== 'idle') {
this.element.classList.add(this.animationState)
}
}
constructor(_element: HTMLDetailsElement, _animationOptions?: KeyframeAnimationOptions) {
this.element = _element
this.summary = _element.querySelector('summary')!
this.content = _element.querySelector('.content')!
if (!this.summary) {
console.error('Summary was not found on this <details> element; therefore, it cannot be inflated, and may not be compliant with web standards')
} else if (!this.content) {
console.warn('Content was not found on this <details> element; therefore, it cannot be inflated')
} else {
this.summary.addEventListener('click', (e) => this.onClick(e))
this.animationOptions = _animationOptions
this.element.classList.add('embed', 'details')
}
}
/**
* Handler for when the shrink/expand animations finish
*/
onAnimationFinish(_isOpen: boolean) {
this.element.open = _isOpen
this.animation = null
this._animationState = 'idle'
this.element.style.height = this.element.style.overflow = ''
}
/**
* Handler for user clicking the summary element
*/
onClick(_event: Event) {
_event.preventDefault()
this.element.style.overflow = 'hidden'
if (this.animationState === 'closing' || !this.element.open) {
this.open()
} else if (this.animationState === 'opening' || this.element.open) {
this.shrink()
}
}
/**
* Shrinks the <details> block with animation
*/
shrink() {
this._animationState = 'closing'
const startHeight = `${this.element.offsetHeight}px`
const endHeight = `${this.summary.offsetHeight}px`
if (!!this.animation) {
this.animation.cancel()
}
this.animation = this.element.animate({
height: [ startHeight, endHeight ]
}, this.animationOptions)
this.animation.onfinish = () => this.onAnimationFinish(false)
this.animation.oncancel = () => this._animationState = 'idle'
}
/**
* Prepares the <details> block for expanding
*/
open() {
this.element.style.height = `${this.element.offsetHeight}px`
this.element.open = true
window.requestAnimationFrame(() => this.expand())
}
/**
* Expands the <details> block with animation
*/
expand() {
this._animationState = 'opening'
const startHeight = `${this.element.offsetHeight}px`
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`
if (!!this.animation) {
this.animation.cancel();
}
this.animation = this.element.animate({
height: [ startHeight, endHeight ]
}, this.animationOptions)
this.animation.onfinish = () => this.onAnimationFinish(true)
this.animation.oncancel = () => this._animationState = 'idle'
}
}

View file

@ -0,0 +1,17 @@
import { inflateDescriptionListEmbeds } from './descriptionList'
import { inflateDetailsElements } from './details'
import { inflateImageEmbeds } from './image'
import { inflateVideoEmbeds } from './video'
import type { ProjectListingInfo } from 'src/views/project/project'
/**
* Inflates various supported embeds
* @param _window the reference to the window
*/
export const inflateEmbeds = (_projectInfo?: ProjectListingInfo) => {
inflateDescriptionListEmbeds(_projectInfo)
inflateDetailsElements()
inflateImageEmbeds()
inflateVideoEmbeds()
}

View file

@ -0,0 +1,51 @@
//import { ModalService } from 'src/components/services/modal'
/**
* Queries for HTMLImageElement nodes and inflates them with extra functionality
* @param _window the reference to the window
*/
export const inflateImageEmbeds = () => {
document.querySelectorAll('.content img:not(.no-inflate)').forEach((_element) => {
new ImageElement(_element as HTMLImageElement)
})
}
/**
* Inflates HTMLImageElement with extra functionality,
* namely previewing images in the modal service and displaying captions from titles
*/
export class ImageElement {
element: HTMLImageElement
caption: HTMLParagraphElement | null = null
wrapper: HTMLDivElement
constructor(_element: HTMLImageElement) {
this.element = _element
const parent = this.element.parentElement!
this.wrapper = document.createElement('div')
this.wrapper.classList.add('embed', 'image')
const subWrapper = document.createElement('div')
subWrapper.classList.add('image-wrapper')
parent.appendChild(this.wrapper)
this.wrapper.appendChild(subWrapper)
subWrapper.appendChild(this.element)
if (!!this.element.title) {
this.caption = document.createElement('p')
this.wrapper.appendChild(this.caption)
this.caption.innerHTML = this.element.title
}
this.element.addEventListener('click', this.onClick)
}
/**
* Handler for when the image is clicked
* @param event the click event
*/
onClick = (event: Event) => {
// event.preventDefault()
// let cloned = this.wrapper.cloneNode(true) as HTMLElement
// let image = cloned.firstChild as HTMLElement
// image.removeEventListener('click', this.onClick)
// ModalService.open(cloned)
}
}

View file

@ -0,0 +1,29 @@
import { findFirstMatchingChild } from 'src/utilities/dom'
/**
* Queries for iframe, video, or object nodes and inflates them with extra functionality
* @param _window the reference to the window
*/
export const inflateVideoEmbeds = () => {
document.querySelectorAll('.video:not(.no-inflate)').forEach((_element) => {
new VideoElement(_element)
})
}
/**
* Inflates iframe, video, or object elements with extra functionality, namely embed styles
*/
export class VideoElement {
element: Element
videoElement: HTMLIFrameElement | HTMLVideoElement | HTMLObjectElement
constructor(_element: Element) {
this.element = _element
this.videoElement = findFirstMatchingChild(_element, 'iframe', 'video', 'object') as any
if (!this.videoElement) {
console.warn('A video embed was defined but contained no video elements within it')
} else {
this.element.classList.add('embed', 'video')
}
}
}

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { inflateEmbeds } from 'src/components/inflators/embed'
const props = defineProps<{
content: string
}>()
onMounted(() => {
inflateEmbeds()
})
</script>
<template lang="pug">
.content(
v-html='content'
)
</template>
<style scope lang="sass">
</style>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type HeaderEntry } from 'content/routes.js'
import { useRouteStore } from 'src/routes'
const props = defineProps<{
entry: HeaderEntry,
}>()
const routeStore = useRouteStore()
const name = computed(() => !!(props.entry as any).path ? routeStore._routes[(props.entry as any).path].name : null)
const sameSite = (props.entry as any).path?.startsWith('/')
</script>
<template lang="pug">
li.header-entry
span(
v-if='entry.children'
tabindex=0
) {{ entry.displayName }}
ul
HeaderLink(
v-for='entry in entry.children'
:entry='entry'
)
a(
v-else-if='!sameSite'
:href='entry.path'
:target='entry.target || "_self"'
) {{ entry.displayName }}
router-link(
v-else
:to='{ name }'
:target='entry.target || "_self"'
) {{ entry.displayName }}
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { injectStylesheet } from 'src/utilities/dom'
import { storage } from 'src/utilities/fetch'
import { useRouteStore } from 'src/routes'
const routeStore = useRouteStore()
const globalConfig = routeStore._globals
const options = ref(globalConfig.themes)
const currentTheme = ref('')
let node: HTMLLinkElement
const onThemeChosen = (event: Event) => {
storage.write(`${globalConfig.id}::currentTheme`, currentTheme.value)
node.setAttribute('href', globalConfig.themes[currentTheme.value].url)
}
onMounted(async () => {
currentTheme.value = storage.read(`${globalConfig.id}::currentTheme`) || Object.keys(globalConfig.themes)[0]
node = injectStylesheet(globalConfig.themes[currentTheme.value].url, 'theme-stylesheet')
})
</script>
<template lang="pug">
.theme-picker
select(
v-model='currentTheme'
@change='onThemeChosen($event)'
)
option(
v-for='(option, key) in options'
:value='key'
) {{ option.displayName|| option }}
</template>
<style scoped lang="sass">
</style>

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { storage } from 'src/utilities/fetch'
import type { WarningModal } from 'content/routes.js'
import EmbedableContent from './embedable-content.vue'
const props = defineProps<{
storageId: string
warning: WarningModal
}>()
const emits = defineEmits<{
(e: 'acknowledged'): void
(e: 'canceled'): void
}>()
const router = useRouter()
const rememberChecked = defineModel('rememberChecked', { type: Boolean })
const onLeave = (event: Event) => {
event.preventDefault()
router.push({ path: props.warning?.leave?.url || '/' })
}
const onAcknowledge = (event: Event) => {
event.preventDefault()
if (rememberChecked.value) {
storage.write(`${props.storageId}::rememberWarning`, true)
}
emits('acknowledged')
}
</script>
<template lang="pug">
.warning-prompt
EmbedableContent(
:content='warning.prompt'
)
.actions
button.input(
@click='onLeave($event)'
)
span {{ warning.leave.text }}
button.input(
@click='onAcknowledge($event)'
)
span {{ warning.acknowledge }}
.input.labeled-checkbox
label(
for='warning-prompt-checkbox'
) {{ warning.remember }}
input(
type='checkbox'
name='warning-prompt-checkbox'
id='warning-prompt-checkbox'
v-model='rememberChecked'
)
</template>
<style scoped lang="sass">
</style>

153
projects/frontend/src/content-env.d.ts vendored Normal file
View file

@ -0,0 +1,153 @@
declare module 'content/routes.js' {
/**
* Defines the structure of the warning modal that shows when a route has a content warning of some sort.
*/
export type WarningModal = {
prompt: string
leave: {
url: string,
text: string,
},
acknowledge: string,
remember: string,
}
/**
* Defines the available `views` that can be used to set up routes.
* Each `Template` has different configuration options
*/
type Template =
| 'markdown'
| 'project-list'
| 'gallery-list'
/**
* Defines the shared options for a route
*/
type SharedRouteDefinition = {
id: string
scriptUrl?: string
stylesheetUrls: string[]
template: Template
title: string
warning: boolean | WarningModal
}
/**
* Defines a content-based route
*/
type ContentfulRouteDefintion = SharedRouteDefinition & {
content: string
}
/**
* Defines a config-based route
*/
type ConfigfulRouteDefinition = SharedRouteDefinition & {
config: string
}
/**
* Defines the config for a route using the `markdown` {@link Template}
*/
type MarkdownDefinition = ContentfulRouteDefintion & {
template: 'markdown'
}
/**
* Defines the config for a route using the `project-list` {@link Template}
*/
type ProjectListDefinition = ConfigfulRouteDefinition & {
template: 'project-list'
view: {
stylesheetUrls: string[]
}
}
/**
* Defines the config for a route using the `gallery-list` {@link Template}
*/
type GalleryListDefinition = ConfigfulRouteDefinition & {
template: 'gallery-list'
view: {
stylesheetUrls: string[]
}
}
/**
* Defines all available route definitions
*/
type RouteDefinition =
| MarkdownDefinition
| ProjectListDefinition
| GalleryListDefinition
/**
* Defines the collection of routes the app uses
*/
type RouteCollection = { [key: string]: RouteDefinition }
/**
* The routes the app uses
*/
const routes: RouteCollection
/**
* Defines an entry in the app header.
* Optionally recursive by defining children instead of path.
*/
type HeaderEntry = {
displayName: string
} & ({
path: string
target?:
| '_self'
| '_blank'
| '_parent'
| '_top'
| '_unfencedTop'
} | {
children: HeaderEntry[]
})
/**
* Defines what kind of theme a specified theme is
* for the purposes of determining a default theme
* based on the system/browser setting of the visitor
*/
type SiteThemeType =
| 'dark'
| 'light'
| 'dark_hc'
| 'light_hc'
/**
* Defines a stylesheet the site uses for applying a theme
*/
type SiteTheme = {
displayName?: string
type: SiteThemeType
url: string
}
/**
* Defines a list of themes the site uses
*/
type SiteThemeList = { [id: string]: SiteTheme }
/**
* Defines global values for the site.
*/
type SiteGlobals = {
header: HeaderEntry[]
id: string
stylesheetUrls: string[]
themes: SiteThemeList
warning: WarningModal
}
/**
* Global values the site uses.
*/
const siteGlobals: SiteGlobals
}

View file

@ -0,0 +1,2 @@
@import 'normalize.css'
@import './styles/embed.sass'

View file

@ -0,0 +1,33 @@
import { ViteSSG } from 'vite-ssg'
import { createPinia } from 'pinia'
import hljs from 'highlight.js'
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import main from './main.vue'
import './main.sass'
import { createRoutes, initializeRouteStore } from './routes'
import { headingSectionsExtension } from './utilities/marked'
marked
.use(headingSectionsExtension() as any)
.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, { language }).value
},
}))
export const createApp = ViteSSG(
// the root component
main,
// vue-router options
{ routes: createRoutes() },
// function to have custom setups
({ app, router, routes, isClient, initialState }) => {
app.use(createPinia())
initializeRouteStore(routes)
},
)

View file

@ -0,0 +1,161 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getCurrentRoute } from 'src/utilities/vuetils'
import { injectStylesheet } from 'src/utilities/dom'
import { storage } from './utilities/fetch'
import { useRouteStore } from 'src/routes'
import type { WarningModal } from 'content/routes.js'
import HeaderLink from 'src/components/shared/header-link.vue'
import ThemePicker from 'src/components/shared/theme-picker.vue'
import WarningPrompt from 'src/components/shared/warning-prompt.vue'
const router = useRouter()
const routeStore = useRouteStore()
const globalConfig = routeStore._globals
let currentRoute = getCurrentRoute()
let routeConfig = routeStore._routes[currentRoute.path]
let rememberWarning = false
const ready = ref(false)
const firstInit = ref(false)
const acknowledged = ref(false)
const storageId = ref('')
const routeId = ref('')
const warning = ref({} as WarningModal)
const determineWarning = () => {
if (!routeConfig.warning || routeStore.doesRouteRememberWarning(currentRoute.path)) {
acknowledged.value = true
return
}
rememberWarning = storage.read(`${storageId.value}::rememberWarning`) || false
if (rememberWarning) {
acknowledged.value = true
routeStore.rememberRouteWarning(currentRoute.path)
return
}
warning.value = routeConfig.warning === true
? globalConfig.warning
: routeConfig.warning
}
const determineStylesheets = (stylesheetUrls?: string[]) => {
const staleStylesheets = document.head.querySelectorAll('link.page-stylesheet[rel="stylesheet"]')
staleStylesheets.forEach(stylesheet => {
document.head.removeChild(stylesheet)
})
stylesheetUrls?.forEach(stylesheet => {
injectStylesheet(stylesheet, 'page-stylesheet')
})
}
const determineGlobalStylesheets = () => {
globalConfig.stylesheetUrls.forEach(stylesheet => {
injectStylesheet(stylesheet, 'global-stylesheet')
})
}
const determineScript = async () => {
if (!!routeConfig.scriptUrl) {
const { callbacks } = await import(/* @vite-ignore */ routeConfig.scriptUrl)
if (!!callbacks.onPageLoaded) {
callbacks.onPageLoaded();
}
}
return null
}
const refresh = async () => {
ready.value = false
acknowledged.value = false
currentRoute = getCurrentRoute()
routeConfig = routeStore._routes[currentRoute.path]
storageId.value = `${globalConfig.id}::${currentRoute.path}`
routeId.value = routeStore._routes[currentRoute.path].id
determineWarning()
determineStylesheets(routeConfig.stylesheetUrls)
scrollTo({
top: 0,
})
setTimeout(() => ready.value = true)
}
const onAcknowledgedWarning = () => {
acknowledged.value = true
routeStore.rememberRouteWarning(currentRoute.path)
}
const onRouteLoaded = async () => {
await determineScript()
}
const handlePageClosed = async (scriptUrl?: string) => {
if (!!scriptUrl) {
const { callbacks } = await import(/* @vite-ignore */ scriptUrl)
if (!!callbacks.onPageClosed) {
callbacks.onPageClosed()
}
}
}
onMounted(async () => {
await refresh()
firstInit.value = true
determineGlobalStylesheets()
router.afterEach(async (to, from) => {
const oldUrl = routeConfig.scriptUrl
await refresh()
await handlePageClosed(oldUrl)
})
})
</script>
<template lang="pug">
#main-container
header(
v-if='firstInit && !!globalConfig.header'
)
ul
HeaderLink(
v-for='entry in globalConfig.header'
:entry='entry'
)
ThemePicker
main(
v-if='ready'
)
#main-entry(
v-if='acknowledged'
)
router-view(
v-slot='{ Component }'
@loaded='onRouteLoaded'
)
Transition
component(
:id='routeId'
:is='Component'
)
#main-entry(
v-else
)
Transition
WarningPrompt(
:storageId='storageId'
:warning='warning'
@acknowledged='onAcknowledgedWarning()'
)
</template>
<style scoped lang="sass">
</style>

View file

@ -0,0 +1,99 @@
import { defineStore } from 'pinia'
import { type RouteRecordRaw } from 'vue-router'
import {
routes,
siteGlobals,
type GalleryListDefinition,
type HeaderEntry,
type ProjectListDefinition,
type RouteDefinition,
type SiteGlobals,
type Template,
} from 'content/routes.js'
const markdownBody = () => import ('./views/markdown/markdown.vue')
const projectListBody = () => import ('./views/project/project-list.vue')
const projectViewBody = () => import ('./views/project/project-view.vue')
const galleryListBody = () => import ('./views/gallery/gallery-list.vue')
const galleryViewBody = () => import ('./views/gallery/gallery-view.vue')
export const templates: Record<Template, () => Promise<any>> = {
'markdown': markdownBody,
'project-list': projectListBody,
'gallery-list': galleryListBody,
}
export const createRoutes = (): RouteRecordRaw[] => {
const routeRecord: RouteRecordRaw[] = []
Object.keys(routes).forEach(route => {
const toPush: RouteRecordRaw = {
name: routes[route].id,
path: route,
component: templates[routes[route].template],
}
if (routes[route].template === 'project-list') {
routeRecord.push({
name: `${routes[route].id}: View Project`,
path: `${route}/view`,
component: projectViewBody,
props: route => ({ id: route.query.id }),
})
} else if (routes[route].template === 'gallery-list') {
toPush.props = route => ({ variants: (route.query.v as string || '').split(';') })
routeRecord.push({
name: `${routes[route].id}: View Entry`,
path: `${route}/view`,
component: galleryViewBody,
props: route => ({ variants: (route.query.v as string || '').split(';') }),
})
}
routeRecord.push(toPush)
})
return routeRecord
}
export const useRouteStore = defineStore('routeStore', {
state: () => ({
_header: [] as HeaderEntry[],
_routes: {} as Record<string, RouteRecordRaw & RouteDefinition>,
_globals: {} as SiteGlobals,
_routesAlreadyWarned: {} as Record<string, boolean>
}),
actions: {
doesRouteRememberWarning(route: string) {
return this._routesAlreadyWarned[route]
},
rememberRouteWarning(route: string) {
this._routesAlreadyWarned[route] = true
},
},
})
export const initializeRouteStore = (routerRoutes: readonly RouteRecordRaw[]) => {
const routeStore = useRouteStore()
Object.keys(routes).forEach(route => {
routeStore._routes[route] = {
...routerRoutes.find(other => other.path === route) as RouteRecordRaw,
...routes[route] as RouteDefinition,
}
if (routes[route].template === 'project-list' || routes[route].template === 'gallery-list') {
routeStore._routes[`${route}/view`] = {
...routerRoutes.find(other => other.path === `${route}/view`) as RouteRecordRaw,
...(routes[route] as ProjectListDefinition | GalleryListDefinition).view,
} as any
}
})
routeStore._globals = siteGlobals
}
export type RouteStoreDefinition = Omit<
ReturnType<typeof useRouteStore>,
keyof ReturnType<typeof defineStore>
>

View file

@ -0,0 +1,99 @@
@import './mixins.scss'
@import './tag.sass'
#modal-outlet
position: fixed
top: 1em
left: 1em
bottom: 1em
right: 1em
z-index: 99
&::before
content: ''
position: absolute
top: -1em
left: -1em
bottom: -1em
right: -1em
background: var(--theme-modal-overlay)
z-index: -1
&[state="closed"]
opacity: 0
pointer-events: none
.container
width: 100%
height: 100%
@include themeColors(var(--brand-champagne-pink), var(--brand-dark-purple))
.close
height: 2em
cursor: pointer
margin-left: auto
.content
width: 100%
height: calc(100% - 2em)
overflow: scroll
img
margin: auto
display: block
max-width: calc(100vw - 2em)
max-height: calc(100vh - 4em)
.embed
video,
iframe,
object
border: none
.image-wrapper
display: flex
img
cursor: pointer
max-width: 100%
max-height: 100%
width: auto
height: auto
margin: auto
details
summary
cursor: pointer
details.embed
summary
display: block
position: relative
padding-left: 1em
&::before
@include svgMask('/assets/icons/triangle.svg', contain)
content: ""
display: block
position: absolute
top: 0
left: 0
width: 1em
height: 1em
background-color: var(--theme-body-fg)
transform: rotate(90deg)
animation-duration: var(--theme-transition-duration)
animation-timing-function: var(--theme-transition-function)
details.embed[open=""]
summary
&::before
transform: rotate(180deg)
details.embed.closing
summary
&::before
animation-name: details-arrow
animation-direction: reverse
details.embed.opening
summary
&::before
animation-name: details-arrow
animation-direction: normal
@keyframes details-arrow
0%
transform: rotate(90deg)
100%
transform: rotate(180deg)

View file

@ -0,0 +1,87 @@
@mixin positioning(
$top: unset,
$left: unset,
$bottom: unset,
$right: unset,
) {
top: $top;
left: $left;
bottom: $bottom;
right: $right;
}
@mixin svgMask(
$url,
$size: contain,
) {
-webkit-mask-image: url($url);
mask-image: url($url);
-webkit-mask-size: $size;
mask-size: $size;
}
@mixin themeColors(
$bg,
$fg,
$link: $fg,
) {
background: $bg;
color: $fg;
a {
color: $link;
}
}
@mixin autoMaxSize(
$maxHeight,
$maxWidth: $maxHeight,
$height: auto,
$width: auto,
) {
height: auto;
max-height: $maxHeight;
max-width: $maxWidth;
width: auto;
}
@mixin size(
$height,
$width: $height,
) {
height: $height;
width: $width;
}
@mixin transition(
$props...
) {
transition-duration: var(--theme-transition-duration);
transition-property: $props;
transition-timing-function: var(--theme-transition-function);
}
@mixin flex(
$align: unset,
$justify: unset,
$direction: row,
$gap: 0,
$wrap: unset,
) {
align-items: $align;
display: flex;
flex-direction: $direction;
flex-wrap: $wrap;
gap: $gap;
justify-content: $justify;
}
@mixin grid(
$columns: unset,
$rows: unset,
$gap: 0,
) {
display: grid;
grid-template-columns: $columns;
grid-template-rows: $rows;
gap: $gap;
}

View file

@ -0,0 +1,94 @@
.tag
&.angular
background-color: #c3002f
color: #fff
&.audacity
background-color: #00c
color: #f3e517
&.blender
background-color: #e87d0d
color: #fff
&.dx11
background-color: #000
color: #b7cf87
&.dx12
background-color: #000
color: #29ac21
&.electron
background-color: #2b2e3a
color: #9feaf9
&.github
background-color: #24292f
color: #fff
&.gitlab
background-color: #e24329
color: #fff
&.gnuimp
background-color: #847c62
color: #fff
&.lmms
background-color: #27ab5f
color: #fff
&.monogame
background-color: #e73c00
color: #fff
&.node
background-color: #333
color: #6c3
&.react
background-color: #61dafb
color: #20232a
&.unity
background-color: #4c4c4c
color: #fff
&.unreal
background-color: #1d1931
color: #33a5f5
&.vs
background-color: #8661c5
color: #fff
&.vscode
background-color: #0065a9
color: #fff
&.vue
background-color: #41b883
color: #34495e
&.xamarin
background-color: #3498db
color: #fff
&.cs
background-color: #05930c
color: #fff
&.css
background-color: #2965f1
color: #fff
&.cpp
background-color: #00599c
color: #fff
&.glsl
background-color: #5586a4
color: #fff
&.hlsl
background-color: #000
color: #fff
&.html
background-color: #f14a29
color: #000
&.java
background-color: #5382a1
color: #f8981d
&.js
background-color: #f7df1e
color: #000
&.php
background-color: #777bb3
color: #000
&.sass
background-color: #c69
color: #fff
&.ts
background-color: #358ef1
color: #faf9f8
&.xml
background-color: #f14a29
color: #000

View file

@ -0,0 +1,4 @@
export type DateRange = {
from: string
to: string
}

View file

@ -0,0 +1,5 @@
export interface Link {
caption?: string
href: string
target?: '_blank' | '_parent' | '_self' | '_top'
}

View file

@ -0,0 +1,48 @@
import rfdc from 'rfdc'
import { type DateRange } from 'src/types/shared/dateRange'
export const deepCopy = rfdc()
/**
* Injects a stylesheet link into th ehead of the document
* @param url the url of the stylesheet
* @param className the classname to give the stylesheet
*/
export const injectStylesheet = (url: string, className: string) => {
const newElement = document.createElement('link')
newElement.setAttribute('rel', 'stylesheet')
newElement.setAttribute('href', url)
newElement.classList.add(className)
document.head.appendChild(newElement)
return newElement
}
/**
* Runs element.querySelector for every selector in the order they were passed in until an element is found
* @param element the element to query children from
* @param selectors the selectors to query for
* @returns the first child element found from the selectors specified
*/
export const findFirstMatchingChild = ( element: ParentNode, ...selectors: string[] ) => {
let returnElement: Element | undefined
selectors.every((selector) => {
const foundElement = element.querySelector(selector)
if (!!foundElement) {
returnElement = foundElement
return false
}
return true
})
return returnElement
}
/**
* Formats the period object into a readable string
* @param period the period to format
* @returns the formatted string
*/
export const getFormattedPeriod = (period: DateRange) => {
return `${period.from}${!!period.to ? ' - ' + period.to : ''}`
}

View file

@ -0,0 +1,190 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import yaml from 'js-yaml'
/**
* Config used for DOMPurify.
* This config allows for most HTML elements and a handful of attributes
* necessary for the site to load intended markdown/etc
*/
const domPurifyConfig = {
ALLOW_ARIA_ATTR: true,
ALLOWED_ATTR: [
'allowfullscreen',
'allow',
'alt',
'class',
'href',
'id',
'src',
'style',
'title',
],
ALLOWED_TAGS: [
'a',
'p',
'span',
'b',
'em',
'i',
'mark',
's',
'sub',
'sup',
'small',
'strike',
'strong',
'u',
'abbr',
'audio',
'blockquote',
'cite',
'code',
'details',
'data',
'dfn',
'div',
'iframe',
'img',
'pre',
'summary',
'samp',
'time',
'track',
'var',
'video',
'figcaption',
'figure',
'br',
'hr',
'wbr',
'ol',
'ul',
'li',
'dd',
'dl',
'dt',
],
}
/**
* Fetches a file and returns the blob content of the file
* @param path the path of the file to load
* @returns the blob of the file
*/
export const fetchAndReturnBlob = async (path: string) => {
const response = await fetch(path)
const blob = await response.blob()
return blob
}
/**
* Fetches and sanitizes text files
* @param path the path of the text file to load
* @returns the content of the text file after sanitizing
*/
export const fetchAndReturnText = async (path: string) => {
const blob = await fetchAndReturnBlob(path)
const text = await blob.text()
const sanitized = DOMPurify.sanitize(text, domPurifyConfig)
return sanitized
}
/**
* Fetches, sanitizes, and parses YAML files
* @param path the path of the YAML file to load
* @returns the content of the YAML file after sanitizing then parsing
*/
export const fetchAndParseYaml = async <T>(path: string) => {
const text = await fetchAndReturnText(path)
return yaml.load(text) as T
}
/**
* Fetches, sanitizes, and parses Markdown files
* @param path the path of the markdown document to load
* @returns the project parsed from the markdown document
*/
export const fetchAndParseMarkdown = async (path: string) => {
const document = await fetchAndReturnText(path)
return marked.parse(document)
}
/**
* Fetches content type from an image
* @param path the path of the file to check
* @returns the Content-Type header of the file
*/
export const getContentType = async (path: string) => {
const response = await fetch(path, {method: 'HEAD'})
return response.headers.get('Content-Type')
}
/**
* Determines if a url is a valid image
* @param url the url of the image
* @returns true if valid image, false otherwise
*/
export const isValidImage = async (url?: string): Promise<boolean> => {
if (!url) {
return false
}
const contentType = await getContentType(url)
return contentType?.startsWith('image') || false
}
/**
* Creates a Promise which waits for a condition to be true
* @param condition the condition which should be met
* @param interval the period of time (ms) to wait between attempts
* @param tries the number of attempts (up to and including this number) to try before determining the condition couldn't be met
* @returns A Promise which will resolve when the condition is met or reject if timed out after a period of `(interval * tries)`
*/
export const awaitCondition = (condition: () => boolean, interval: number = 100, tries: number = 100) => {
return new Promise<void>((resolve, reject) => {
let _attempts = 1
const wait: any = () => {
_attempts += 1
if (condition()) {
return resolve()
} else if (_attempts > tries) {
reject()
} else {
setTimeout(wait, interval)
}
}
wait()
})
}
/**
* Methods related to local storage
*/
export const storage = {
/**
* Retrieves a value from local storage
* @param key the key of the local storage item
* @returns the parsed object or null
*/
read: <T>(key: string) => {
const value = window.localStorage.getItem(key)
return !!value
? JSON.parse(value) as T
: null
},
/**
* Stores a value to local storage
* @param key the key of the local storage item
* @param value the object to store
*/
write: <T>(key: string, value: T) => {
window.localStorage.setItem(key, JSON.stringify(value))
},
}

View file

@ -0,0 +1,57 @@
import { MarkedExtension } from 'marked'
import GithubSlugger from 'github-slugger'
let slugger: GithubSlugger
let sectionLevel = 1
const sectionRegexps = new Array(7).fill(undefined).map((_, i) => new RegExp(`^ {0,3}(#{${i + 1}} )[^]*?(?:\\n(?=\\1)|$)`))
const headingRegex = new RegExp(/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/)
/**
* Extension which creates sections for each heading found in a markdown document,
* using the {@link GithubSlugger} to additionally create id's for each section.
* @returns marked extension factory
*/
export const headingSectionsExtension = (): MarkedExtension => {
return {
hooks: {
preprocess(src) {
slugger = new GithubSlugger()
return src
},
postprocess(html) {
return html
}
},
extensions: [{
name: 'headingSections',
level: 'block',
start(src: string) {
// Match when # is at the beginning of a line.
return src.match(/^ {0,3}#/m)?.index
},
tokenizer(src: string) {
const match = sectionRegexps[sectionLevel].exec(src)
if (!match) {
return
}
sectionLevel++
// Tokenize text inside the section.
// Only add sectionBlock token for headers one level up from current level.
const tokens = this.lexer.blockTokens(match[0])
sectionLevel--
return {
type: 'headingSections',
heading: headingRegex.exec(match[0])?.[2].trim(),
raw: match[0],
level: sectionLevel + 1,
tokens,
}
},
renderer(token) {
return `<section id=${slugger.slug(token.heading)}>\n${this.parser.parse(token.tokens!)}</section>\n`
},
}],
}
}

View file

@ -0,0 +1,16 @@
import { useRoute } from 'vue-router'
import { deepCopy } from './dom'
/**
* Gets the current route from useRoute
* after performing some additional checks
* to ensure consistency across the site.
* @returns a slightly modified {@link useRoute} route
*/
export const getCurrentRoute = () => {
const route = deepCopy(useRoute())
if (route.path !== '/' && route.path.endsWith('/')) {
route.path = route.path.substring(0, route.path.length - 1)
}
return route
}

View file

@ -0,0 +1,245 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { type RouteRecordRaw, useRouter } from 'vue-router'
import type { GalleryDisplayedEntries, GalleryList } from './gallery'
import { type GalleryListDefinition } from 'content/routes.js'
import { amendVariantsWithDefaults } from './gallery-utilities'
import { fetchAndParseYaml, storage } from 'src/utilities/fetch'
import { getCurrentRoute } from 'src/utilities/vuetils'
import { useRouteStore } from 'src/routes'
import GalleryTile from './gallery-tile.vue'
const props = defineProps<{
variants: string[]
}>()
/**
* Wraps around the path of variant ids to ensure there are not any issues
* @param variants the array of variant ids
*/
const validateVariantPath = (variants?: string[]) => {
return !!variants && variants[0] !== '' ? variants : null
}
const currentRoute = getCurrentRoute()
const routeStore = useRouteStore()
const routeConfig = routeStore._routes[currentRoute.path] as GalleryListDefinition & RouteRecordRaw
const globalConfig = routeStore._globals
const storageId = `${globalConfig.id}`
const router = useRouter()
let config: GalleryList = null!
let tagsToggled: string[] = []
const ready = ref(false)
const galleryReady = ref(false)
const entries = ref({} as GalleryDisplayedEntries)
const variants = ref(validateVariantPath(props.variants))
const hasWarnings = ref(false)
const hideWarnings = defineModel('showWarnings', { type: Boolean })
const tagsByCategory = ref({} as { [category: string]: Record<string, string> })
/**
* Handles updating the displayed tags in the list
*/
const onLoadTags = () => {
if (!config.tags) {
tagsByCategory.value = null as any
} else {
tagsByCategory.value = { 'NoCategory': {}}
Object.keys(config.tags).forEach(id => {
const tag = config.tags[id]
if (!!tag.category) {
if (!tagsByCategory.value[tag.category]) {
tagsByCategory.value[tag.category] = {}
}
tagsByCategory.value[tag.category][id] = tag.displayName || id
} else {
tagsByCategory.value['NoCategory'][id] = tag.displayName || id
}
})
}
}
/**
* Handles updating the displayed entries in the list
*/
const onDisplayEntries = () => {
resetTags()
galleryReady.value = false
let currentEntries = config.entries
if (!!variants.value) {
variants.value.forEach((variant) => {
currentEntries = amendVariantsWithDefaults(currentEntries[variant])!
})
}
entries.value = currentEntries
hasWarnings.value = !!Object.values(entries.value).find(other => !!other.warning)
setTimeout(() => galleryReady.value = true)
}
/**
* Handler for the tile clicked event;
* handles navigating to the gallery-view route to display the entry selected
* @param clickEvent.event the event context which invoked this handler
* @param clickEvent.id the id of the tile that was clicked
*/
const onTileClicked = (clickEvent: { event: Event, id: string }) => {
const { event, id } = clickEvent
const entry = entries.value[id]
event.preventDefault()
if (!!entry.variants) {
const newPath = !!variants.value
? `${(variants.value || []).join(';')};${id}`
: id
router.push({ name: routeConfig.name, query: { v: newPath }})
variants.value = newPath.split(';')
onDisplayEntries()
} else if (!!variants.value) {
router.push({ name: `${routeConfig.name?.toString()}: View Entry`, query: { v: `${(variants.value || []).join(';')};${id}` }})
} else {
router.push({ name: `${routeConfig.name?.toString()}: View Entry`, query: { v: `${id}` }})
}
}
/**
* Handler for the back button which appears when navigated into entry variants;
* navigates upward one level from the currently displayed variants
*/
const onNavigateBack = (event: Event) => {
event.preventDefault()
let newPath: string | null = variants.value!.slice(0, variants.value!.length - 1).join(';')
if (newPath === '') {
router.push({ name: routeConfig.name})
variants.value = null
} else {
router.push({ name: routeConfig.name, query: { v: newPath }})
variants.value = validateVariantPath(newPath?.split(';'))
}
onDisplayEntries()
}
/**
* Handler for the toggle for hiding/showing warnings;
* updates localstorage with the state of the checkbox
* so that it is saved between page loads
* @param event the event context which invoked this handler
*/
const onHideWarningsToggled = (event: Event) => {
storage.write(`${storageId}::hideWarnings`, (event.target as HTMLInputElement).checked)
}
/**
* Handler for a tag being selected;
* updates the visibility state of the current entries
* @param event the event context which invoked this handler
* @param tagId: the id of the tag
*/
const onToggleTag = (event: Event, tagId: string) => {
if ((event.target as HTMLInputElement).checked) {
tagsToggled.push(tagId)
} else {
const index = tagsToggled.indexOf(tagId)
if (index > -1) {
tagsToggled.splice(index, 1)
}
}
if (tagsToggled.length < 1) {
Object.keys(entries.value).forEach(entryId => {
entries.value[entryId].hidden = false
})
} else {
Object.keys(entries.value).forEach(entryId => {
entries.value[entryId].hidden = !entries.value[entryId].tags?.some(own => tagsToggled.includes(own))
})
}
}
/**
* Resets the `hidden` state of entries
* and the `tagsToggled` array
*/
const resetTags = () => {
Object.keys(entries.value).forEach(entryId => {
entries.value[entryId].hidden = false
})
tagsToggled = []
}
onMounted(async () => {
ready.value = false
config = await fetchAndParseYaml<GalleryList>(routeConfig.config)
document.title = routeConfig.title
hideWarnings.value = storage.read(`${storageId}::hideWarnings`) || false
onLoadTags()
onDisplayEntries()
ready.value = true
})
</script>
<template lang="pug">
.template.gallery-list
.navigation(
v-if='ready'
)
button.input(
v-if='variants?.length > 0'
@click='onNavigateBack($event)'
)
span Back
.input.labeled-checkbox(
v-if='hasWarnings'
)
label(
for='warning-toggle-checkbox'
) Hide Warnings
input(
type='checkbox'
name='warning-toggle-checkbox'
id='warning-toggle-checkbox'
v-model='hideWarnings'
@input='onHideWarningsToggled($event)'
)
Transition
.filters(
v-if='galleryReady && !!tagsByCategory'
)
h2 Filters
.category(
v-for='(tags, category) in tagsByCategory'
:id='category'
)
h3(
v-if='category !== "NoCategory"'
) {{ category }}
.input.labeled-checkbox(
v-for='(tagDisplayName, tagId) in tags'
:id='tagId'
)
label(
:for='`${tagId}-toggle`'
) {{ tagDisplayName }}
input(
type='checkbox'
:name='`${tagId}-toggle`'
:id='`${tagId}-toggle`'
@input='onToggleTag($event, tagId)'
)
Transition
.gallery(
v-if='galleryReady'
)
Transition(
v-for='(entry, id) in entries'
)
GalleryTile(
v-if='!entry.hidden'
:entry='entry'
:id='id'
:hideWarnings='hideWarnings'
@click='onTileClicked($event)'
)
</template>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { GalleryEntry } from './gallery'
import { getTitleFromEntryOrId } from './gallery-utilities'
const props = defineProps<{
entry: GalleryEntry,
id: string,
hideWarnings: boolean,
}>()
defineEmits<{
(e: 'click', value: { event: Event, id: string }): void
}>()
const title = computed(() => getTitleFromEntryOrId(props.entry, props.id))
const styles = computed(() => {
const stylesReturn: Record<string, string> = {}
if (!!props.entry.thumbnailBackground) {
stylesReturn.background = props.entry.thumbnailBackground
}
stylesReturn['object-position'] = props.entry.thumbnailPosition || 'center center'
return stylesReturn
})
</script>
<template lang="pug">
.gallery-embed(
@click='$emit("click", { event: $event, id })'
)
.image-wrapper(
:class='{ warning: !!entry.warning && !hideWarnings }'
)
img(
:src='entry.thumbnailUrl || entry.url'
:alt='entry.description || id'
:style='styles'
)
.caption-wrapper
p.variants(
v-if='!!entry.variants'
) has variants
p {{ title }}
p.warning(
v-if='!!entry.warning'
) {{ entry.warning }}
</template>
<style scoped lang="sass">
</style>

View file

@ -0,0 +1,49 @@
import { deepCopy } from 'src/utilities/dom'
import type { GalleryEntry, GalleryEntryInheritedProperties } from './gallery'
export const getTitleFromEntryOrId = (entry: GalleryEntry, id: string) => (
entry.title !== undefined
? entry.title === '' || entry.title === null
? 'untitled'
: entry.title
: id
)
export const amendVariantsWithDefaults = (entry: GalleryEntry) => {
const variants = deepCopy(entry.variants)
if (!!variants) {
Object.keys(variants).forEach(id => _amendVariantWithDefaults(entry, variants[id]))
}
return variants
}
export const _amendVariantWithDefaults = (parent: GalleryEntryInheritedProperties, variant: GalleryEntryInheritedProperties) => {
if (variant.title === undefined && (!!parent.title || parent.title === null || parent.title === '')) {
variant.title = parent.title
}
if (!variant.description && !!parent.description) {
variant.description = parent.description
}
if (variant.warning === undefined && !!parent.warning) {
variant.warning = parent.warning
}
if (!variant.fields && !!parent.fields) {
variant.fields = parent.fields
}
if (!variant.tags && !!parent.tags) {
variant.tags = parent.tags
}
if (!variant.thumbnailPosition && !!parent.thumbnailPosition) {
variant.thumbnailPosition = parent.thumbnailPosition
}
if (!variant.thumbnailBackground && !!parent.thumbnailBackground) {
variant.thumbnailBackground = parent.thumbnailBackground
}
if (!variant.background && !!parent.background) {
variant.background = parent.background
}
return variant
}

View file

@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { GalleryEntry, GalleryList } from './gallery'
import { type GalleryListDefinition } from 'content/routes.js'
import { amendVariantsWithDefaults, getTitleFromEntryOrId } from './gallery-utilities'
import { fetchAndParseYaml } from 'src/utilities/fetch'
import { getCurrentRoute } from 'src/utilities/vuetils'
import { useRouteStore } from 'src/routes'
import EmbedableContent from 'src/components/shared/embedable-content.vue'
const props = defineProps<{
variants: string[]
}>()
const emits = defineEmits<{
(e: 'loaded'): void
}>()
const ready = ref(false)
const config = ref(null! as GalleryList)
const entry = ref(null! as GalleryEntry)
const id = ref('')
const currentRoute = getCurrentRoute()
const routeStore = useRouteStore()
const routeConfig = routeStore._routes[currentRoute.path.substring(0, currentRoute.path.length - 5)] as GalleryListDefinition
const routeSubConfig = routeStore._routes[currentRoute.path]
const title = ref('')
const styles = computed(() => {
const stylesReturn: Record<string, string> = {}
if (!!entry.value.background) {
stylesReturn.background = entry.value.background
}
return stylesReturn
})
onMounted(async () => {
config.value = await fetchAndParseYaml<GalleryList>(routeConfig.config)
let currentEntries = config.value.entries
for (let i = 0; i < props.variants.length - 1; ++i) {
currentEntries = amendVariantsWithDefaults(currentEntries[props.variants[i]])!
}
entry.value = currentEntries[props.variants[props.variants.length - 1]]
id.value = props.variants[props.variants.length - 1]
title.value = getTitleFromEntryOrId(entry.value, id.value)
document.title = routeSubConfig.title.replace('$ENTRY', title.value)
ready.value = true
emits('loaded')
})
</script>
<template lang="pug">
.template.gallery-view
.view-wrapper(
v-if='ready'
)
.view-outlet
img(
:src='entry.url || entry.thumbnailUrl'
:style='styles'
)
.view-content
p {{ title }}
dl.info
.info-entry(
v-for='(field, key) in entry.fields'
:class='key'
)
dt {{ key }}
dd {{ field }}
EmbedableContent(
:content='entry.description'
)
</template>
<style scoped lang="sass">
</style>

View file

@ -0,0 +1,137 @@
/**
* A partial definition of a {@link GalleryEntry}
* which defines the properties of an entry
* that can be passed down from a top-level entry down to its variants
*/
export type GalleryEntryInheritedProperties = {
/**
* css background applied to the image (useful for transparent images)
*/
background?: string
/**
* [SUPPORTS MARKDOWN] a place for the siteowner to describe the entry
*/
description?: string
/**
* a key-value pair set of general-purpose fields to additionally describe the entry
* @example entry.fields = {
* 'date': '1960/01/01',
* 'rating': 'general',
* }
*/
fields?: Record<string, string>
/**
* array of tag-ids that apply to the art;
* this is used to allow a visitor to filter out entries of a specific tag
* @see {@link GalleryList.tags}
*/
tags: string[]
/**
* css background applied to the thumbnail (useful for transparent images)
*/
thumbnailBackground?: string
/**
* the position of the thumbnail;
* this will be applied as css `object-position`
*/
thumbnailPosition?: string
/**
* the title of the entry
*/
title: string | null | undefined
/**
* any content warnings that apply to the entry,
* which will be used to apply the `.warning` classname
* to the DOM of a gallery tile
* and will be displayed with the tile
*/
warning?: string
}
/**
* Defines an entry in a gallery that can be displayed in a tiled manner
* and can be clicked by a visitor to display its variants or the entry itself if there are none
*/
export type GalleryEntry = GalleryEntryInheritedProperties & {
/**
* the url to the thumbnail to show for the entry in the gallery tile
*/
thumbnailUrl?: string
/**
* the url to the entry itself
*/
url?: string
/**
* optional variants for the entry;
* this is a recursive definition of {@link GalleryEntry entries}
* which can be navigated deeper into in a gallery
* in a manner like a folder in a file-based operating system
*/
variants?: GalleryEntries
}
/**
* Defines the list of entries in a gallery,
* a key-value object where the value is the entry,
* and the key represents the id of a piece;
* it is important for this to be uniquely defined at minimum within
* the scope of the entry in relation to surrounding variants
* in order for the app to properly navigate through variants
* and ultimately the view page displaying the entry
*/
export type GalleryEntries = { [idOrTitle: string]: GalleryEntry }
/**
* A wrapper around {@link GalleryEntries} for the app's use only which adds additional fields
* in order for the app to effectively display the entries.
*/
export type GalleryDisplayedEntries = { [idOrTitle: string]: GalleryEntry & {
/**
* specifies whether the entry is hidden by the tags selected by a visitor
*/
hidden?: boolean
}}
/**
* Defines a tag used by entries in a gallery
* used for filtering entries from view
*/
export type GalleryTag = {
/**
* specifies a category that the tag belongs to
* in order to optionally organize them in the view;
* if category is not specified, tags will be assigned
* "NoCategory" and organized into a section without a heading
* placed before any other sections formed by categories (if any)
*/
category?: string
/**
* specifies the name that the tag will appear as in the DOM;
* if not specified, the id of the tag will be used in its place
*/
displayName?: string
}
/**
* Defines the list of tags in a gallery,
* a key-value object where the value is the entry,
* and the key represents the id of a tag;
* the id of a tag must be unique,
* and the ids specified in a gallery entry must match
* the ids specified in `GalleryTags` in order for them to work effectively
*/
export type GalleryTags = { [id: string]: GalleryTag }
/**
* Defines the model of the `GalleryList` template
*/
export type GalleryList = {
/**
* the entries to display in a gallery-list template
*/
entries: GalleryEntries
/**
* the tags to use for filtering entries
*/
tags: GalleryTags
}

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import EmbedableContent from 'src/components/shared/embedable-content.vue'
import { type MarkdownDefinition } from 'content/routes.js'
import { fetchAndParseMarkdown } from 'src/utilities/fetch'
import { getCurrentRoute } from 'src/utilities/vuetils'
import { useRouteStore } from 'src/routes'
const emits = defineEmits<{
(e: 'loaded'): void
}>()
const content = ref('')
const currentRoute = getCurrentRoute()
const routeStore = useRouteStore()
const routeConfig = routeStore._routes[currentRoute.path] as MarkdownDefinition
onMounted(async () => {
content.value = await fetchAndParseMarkdown(routeConfig.content)
document.title = routeConfig.title
emits('loaded')
})
</script>
<template lang="pug">
.template.markdown
EmbedableContent(
v-if='content'
:content='content'
)
</template>
<style scoped lang="sass">
</style>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { ProjectList, ProjectListingInfo } from './project'
import { type ProjectListDefinition } from 'content/routes.js'
import { fetchAndParseYaml } from 'src/utilities/fetch'
import { getCurrentRoute } from 'src/utilities/vuetils'
import { useRouteStore } from 'src/routes'
import ProjectTile from './project-tile.vue'
const projectIds = ref([] as string[])
const projects = ref({} as { [key: string]: ProjectListingInfo })
const ready = ref(false)
const currentRoute = getCurrentRoute()
const routeStore = useRouteStore()
const routeConfig = routeStore._routes[currentRoute.path] as ProjectListDefinition
onMounted(async () => {
const config = await fetchAndParseYaml<ProjectList>(routeConfig.config)
projectIds.value = Object.keys(config.projects)
for (let i = 0; i < projectIds.value.length; ++i) {
const id = projectIds.value[i]
projects.value[id] = await fetchAndParseYaml(config.projects[id].config)
}
document.title = routeConfig.title
ready.value = true
})
</script>
<template lang="pug">
.template.project-list
#projects(
v-if='ready'
)
ProjectTile(
v-for='id in projectIds'
:id='id'
:info='projects[id]'
)
</template>
<style scoped lang="sass">
</style>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import { getFormattedPeriod } from 'src/utilities/dom'
import type { ProjectListingInfo } from './project'
import { getCurrentRoute } from 'src/utilities/vuetils'
const props = defineProps<{
id: string
info: ProjectListingInfo
}>()
const currentRoute = getCurrentRoute()
const { thumbnailBackground, thumbnailBackgroundSize } = props.info
const period = computed(() => (!!props.info.period
? getFormattedPeriod(props.info.period)
: undefined
))
const caption = computed(() => marked.parse(props.info.caption || ''))
const summary = computed(() => marked.parse(props.info.summary || ''))
const title = computed(() => marked.parse(props.info.title || ''))
</script>
<template lang="pug">
.project-embed
router-link.link(
:to='{ name: `${currentRoute.name}: View Project`, query: { id: id } }'
:href='`./view?id=${id}`'
:style='{ background: thumbnailBackground, backgroundSize: thumbnailBackgroundSize }'
)
.text
.title(
v-html='title'
)
p.period(
v-if='period'
) {{ period }}
.caption(
v-html='caption'
)
.summary(
v-html='summary'
)
</template>
<style scoped lang="sass">
</style>

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { ProjectList, ProjectListingInfo } from './project'
import { type ProjectListDefinition } from 'content/routes.js'
import { fetchAndParseMarkdown, fetchAndParseYaml } from 'src/utilities/fetch'
import { getCurrentRoute } from 'src/utilities/vuetils'
import { useRouteStore } from 'src/routes'
import EmbedableContent from 'src/components/shared/embedable-content.vue'
import { getFormattedPeriod } from 'src/utilities/dom'
const props = defineProps<{
id: string,
}>()
const emits = defineEmits<{
(e: 'loaded'): void
}>()
const ready = ref(false)
const info = ref(null! as ProjectListingInfo)
const content = ref('')
const currentRoute = getCurrentRoute()
const routeStore = useRouteStore()
const routeConfig = routeStore._routes[currentRoute.path.substring(0, currentRoute.path.length - 5)] as ProjectListDefinition
const routeSubConfig = routeStore._routes[currentRoute.path]
onMounted(async () => {
const config = await fetchAndParseYaml<ProjectList>(routeConfig.config)
info.value = await fetchAndParseYaml<ProjectListingInfo>(config.projects[props.id].config)
const md = await fetchAndParseMarkdown(config.projects[props.id].content)
content.value = md.replace('$PERIOD', getFormattedPeriod(info.value.period!))
document.title = routeSubConfig.title.replace('$PROJECT', info.value.title)
ready.value = true
emits('loaded')
})
</script>
<template lang="pug">
.project
article(
v-if='ready'
)
EmbedableContent(
:content='content'
)
</template>
<style lang="sass">
</style>

View file

@ -0,0 +1,116 @@
import { DateRange } from 'src/types/shared/dateRange'
/**
* This describes aditional information about a project.
* to display when listing the project on the portfolio page.
* At minimum, the title should be specified.
*/
export type ProjectListingInfo = {
/**[Supports Markdown]
* Caption displayed below the title of the project on the projects page
*/
caption?: string
/**
* When defined, the period will be displayed as {from} - {to}
*/
period?: DateRange
/**[Supports Markdown]
* Information to summarize a project
*/
summary?: string
/**
* Tags that correspond to project filters on the portfolio page if defined
*/
tags?: string[]
/**[Supports Markdown]
* The title of the project
*/
title: string
/**[CSS:background]
* Background image, repeat, attachment, and position for the project
*/
thumbnailBackground?: string
/**[CSS:background-size]
* Background image size
*/
thumbnailBackgroundSize?: string
}
/**
* This concatenates project files for entry within the store.
*/
export type ProjectStoreEntry = {
/**
* Content pulled from the projects' markdown (.md) file
*/
content?: string
/**
* Listing information pulled from the projects' yaml (.yml) file
*/
listing?: ProjectListingInfo
}
/**
* Defines a filter category in the filters panel
*/
export type ProjectFilterCategory = {
/**
* The heading of the category
*/
heading: string
/**
* The filters associated with the category, or more categories
*/
filters: FilterDefinition[] | ProjectFilterCategory[]
}
/**
* Defines a filter for a project
*/
export type FilterDefinition = {
/**
* The name to display in the filters panel
*/
displayName: string
/**
* The tag which the filter corresponds to for when defined in {@link ProjectListingInfo.tags}
*/
tag: string
}
/**
* Whenever a filter is toggled in the filters panel, this holds the data regarding that change
*/
export type FilterChangeEvent = {
/**
* The tag that the filter is associated with
*/
tag: string
/**
* The toggle state of the filter
*/
value: boolean
}
/**
* Defines the state of the filters panel
*/
export type FilterState = { [tag: string]: boolean }
export type TagDefinition = {
displayName: string
className: string
}
export type Tag =
| TagDefinition
| string
export type ProjectList = {
projects: { [key: string]: {
config: string
content: string
}}
filters?: FilterDefinition[] | ProjectFilterCategory[]
}

1
projects/frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"content/*": ["./content/*"],
"src/*": ["./src/*"]
},
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "../../tsconfig.node.json" }]
}

View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import * as path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'content': path.resolve(__dirname, './content'),
'src': path.resolve(__dirname, './src'),
},
},
})