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,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[]
}