convert frontend to workspace
This commit is contained in:
parent
7f03e9ef13
commit
c223f2f7af
42 changed files with 108 additions and 64 deletions
13
projects/frontend/index.html
Normal file
13
projects/frontend/index.html
Normal 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>
|
36
projects/frontend/package.json
Normal file
36
projects/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
129
projects/frontend/src/components/inflators/details.ts
Normal file
129
projects/frontend/src/components/inflators/details.ts
Normal 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'
|
||||
}
|
||||
}
|
17
projects/frontend/src/components/inflators/embed.ts
Normal file
17
projects/frontend/src/components/inflators/embed.ts
Normal 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()
|
||||
}
|
51
projects/frontend/src/components/inflators/image.ts
Normal file
51
projects/frontend/src/components/inflators/image.ts
Normal 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)
|
||||
}
|
||||
}
|
29
projects/frontend/src/components/inflators/video.ts
Normal file
29
projects/frontend/src/components/inflators/video.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
36
projects/frontend/src/components/shared/header-link.vue
Normal file
36
projects/frontend/src/components/shared/header-link.vue
Normal 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>
|
39
projects/frontend/src/components/shared/theme-picker.vue
Normal file
39
projects/frontend/src/components/shared/theme-picker.vue
Normal 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>
|
66
projects/frontend/src/components/shared/warning-prompt.vue
Normal file
66
projects/frontend/src/components/shared/warning-prompt.vue
Normal 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
153
projects/frontend/src/content-env.d.ts
vendored
Normal 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
|
||||
}
|
2
projects/frontend/src/main.sass
Normal file
2
projects/frontend/src/main.sass
Normal file
|
@ -0,0 +1,2 @@
|
|||
@import 'normalize.css'
|
||||
@import './styles/embed.sass'
|
33
projects/frontend/src/main.ts
Normal file
33
projects/frontend/src/main.ts
Normal 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)
|
||||
},
|
||||
)
|
161
projects/frontend/src/main.vue
Normal file
161
projects/frontend/src/main.vue
Normal 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>
|
99
projects/frontend/src/routes.ts
Normal file
99
projects/frontend/src/routes.ts
Normal 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>
|
||||
>
|
||||
|
99
projects/frontend/src/styles/embed.sass
Normal file
99
projects/frontend/src/styles/embed.sass
Normal 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)
|
87
projects/frontend/src/styles/mixins.scss
Normal file
87
projects/frontend/src/styles/mixins.scss
Normal 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;
|
||||
}
|
94
projects/frontend/src/styles/tag.sass
Normal file
94
projects/frontend/src/styles/tag.sass
Normal 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
|
4
projects/frontend/src/types/shared/dateRange.ts
Normal file
4
projects/frontend/src/types/shared/dateRange.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type DateRange = {
|
||||
from: string
|
||||
to: string
|
||||
}
|
5
projects/frontend/src/types/shared/link.ts
Normal file
5
projects/frontend/src/types/shared/link.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface Link {
|
||||
caption?: string
|
||||
href: string
|
||||
target?: '_blank' | '_parent' | '_self' | '_top'
|
||||
}
|
48
projects/frontend/src/utilities/dom.ts
Normal file
48
projects/frontend/src/utilities/dom.ts
Normal 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 : ''}`
|
||||
}
|
190
projects/frontend/src/utilities/fetch.ts
Normal file
190
projects/frontend/src/utilities/fetch.ts
Normal 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))
|
||||
},
|
||||
}
|
57
projects/frontend/src/utilities/marked.ts
Normal file
57
projects/frontend/src/utilities/marked.ts
Normal 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`
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
16
projects/frontend/src/utilities/vuetils.ts
Normal file
16
projects/frontend/src/utilities/vuetils.ts
Normal 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
|
||||
}
|
245
projects/frontend/src/views/gallery/gallery-list.vue
Normal file
245
projects/frontend/src/views/gallery/gallery-list.vue
Normal 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>
|
52
projects/frontend/src/views/gallery/gallery-tile.vue
Normal file
52
projects/frontend/src/views/gallery/gallery-tile.vue
Normal 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>
|
49
projects/frontend/src/views/gallery/gallery-utilities.ts
Normal file
49
projects/frontend/src/views/gallery/gallery-utilities.ts
Normal 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
|
||||
}
|
79
projects/frontend/src/views/gallery/gallery-view.vue
Normal file
79
projects/frontend/src/views/gallery/gallery-view.vue
Normal 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>
|
137
projects/frontend/src/views/gallery/gallery.d.ts
vendored
Normal file
137
projects/frontend/src/views/gallery/gallery.d.ts
vendored
Normal 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
|
||||
}
|
37
projects/frontend/src/views/markdown/markdown.vue
Normal file
37
projects/frontend/src/views/markdown/markdown.vue
Normal 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>
|
45
projects/frontend/src/views/project/project-list.vue
Normal file
45
projects/frontend/src/views/project/project-list.vue
Normal 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>
|
51
projects/frontend/src/views/project/project-tile.vue
Normal file
51
projects/frontend/src/views/project/project-tile.vue
Normal 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>
|
||||
|
53
projects/frontend/src/views/project/project-view.vue
Normal file
53
projects/frontend/src/views/project/project-view.vue
Normal 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>
|
116
projects/frontend/src/views/project/project.d.ts
vendored
Normal file
116
projects/frontend/src/views/project/project.d.ts
vendored
Normal 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
1
projects/frontend/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
31
projects/frontend/tsconfig.json
Normal file
31
projects/frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
14
projects/frontend/vite.config.ts
Normal file
14
projects/frontend/vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue