feat!: switch to Nuxt, complete overhaul

This commit is contained in:
2025-07-16 12:47:33 -05:00
parent f5ec1d2264
commit 00c0770388
49 changed files with 7414 additions and 1673 deletions
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import { computed } from 'vue';
const lastIndex = computed(() => props.items.length - 1);
const props = defineProps<
{
items: { text: string; to?: string }[];
} & { class?: HTMLAttributes['class'] }
>();
</script>
<template>
<Breadcrumb :class="cn('w-full bg-gray-100 px-4 py-3', props.class)">
<BreadcrumbList>
<template v-for="(item, index) in items" :key="item.text">
<BreadcrumbSeparator v-if="index !== 0" />
<BreadcrumbItem>
<BreadcrumbLink class="text-gray-600" as-child>
<NuxtLink :to="item.to" v-if="index !== lastIndex">
{{ item.text }}
</NuxtLink>
<span v-else>{{ item.text }}</span>
</BreadcrumbLink>
</BreadcrumbItem>
</template>
</BreadcrumbList>
</Breadcrumb>
</template>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="image-skeleton" />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: "ImageSkeleton",
});
</script>
<style lang="scss">
@use "@/scss/_variables.scss" as *;
.image-skeleton {
width: 100%;
height: 100%;
background-color: $gray-400;
display: block;
border-radius: 3px;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="outer-skeleton">
<div
class="skeleton"
:class="[animated ? undefined : 'no-animate']"
:style="[style, innerStyle]"
/>
</div>
</template>
<style lang="scss">
@use '@/scss/_variables.scss' as *;
.breadcrumb-skeleton {
height: 48px;
& > .card-body {
padding: 0 0 0 1em;
display: flex;
align-items: center;
}
}
.outer-skeleton {
padding: 0.35em 0.3em 0.35em 0.35em;
// .breadcrumb-skeleton > {
// display: inline-block;
// }
.skeleton {
width: 100%;
height: 100%;
display: block;
line-height: 1;
background-size: 200px 100%;
background-repeat: no-repeat;
background-image: linear-gradient(
90deg,
var(--secondary-color, $gray-100),
var(--primary-color, $gray-100),
var(--secondary-color, $gray-100)
);
background-color: var(--secondary-color, $gray-100);
animation: 1.25s ease-in-out 0s infinite normal none running SkeletonLoading;
border-radius: var(--border-radius, 3px);
&.no-animate {
animation: none;
}
}
}
@-webkit-keyframes SkeletonLoading {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
@keyframes SkeletonLoading {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
</style>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
innerStyle: {
type: Object,
default: null,
},
innerClass: {
type: String,
default: '',
},
animated: {
type: Boolean,
default: true,
},
borderRadius: {
type: String,
default: '',
},
primaryColor: {
type: String,
default: '',
},
secondaryColor: {
type: String,
default: '',
},
},
computed: {
style() {
return {
'--primary-color': this.primaryColor,
'--secondary-color': this.secondaryColor,
'--border-radius': this.borderRadius,
};
},
},
});
</script>
@@ -0,0 +1,38 @@
<template>
<div v-if="characters" class="pt-2" :fluid="true">
<BButton
v-for="(character, character_id) in characters"
:id="`character-${character_id}`"
:key="character.name"
squared
class="mx-2 my-1 character-button"
size="sm"
:title="`${character.appearances} Quote${character.appearances > 1 ? 's' : ''}`"
:to="{ name: 'Character', params: { character: character_id } }"
>
{{ character.name }}
<BBadge class="ml-1">
{{ character.appearances }}
</BBadge>
</BButton>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { BButton, BBadge } from 'bootstrap-vue-next'
export default defineComponent({
components: {
BButton,
BBadge,
},
props: {
characters: {
type: Object,
required: true,
},
},
})
</script>
@@ -0,0 +1,47 @@
<template>
<span>
<template v-for="(constituent, index) in texts">
<RouterLink
class="speaker-link"
v-if="constituent.route"
:key="index"
:to="constituent.route"
>
{{ constituent.text }}
</RouterLink>
<span class="speaker-bg" v-else :key="'plain-' + index">{{ constituent }}</span>
</template>
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'DynamicSpeaker',
props: {
text: { type: String, required: true },
characters: { type: Object, required: true },
},
computed: {
texts() {
return this.text.split(/({[^}]+})/).map((item) => {
const id = item.substring(1, item.length - 1)
if (item.startsWith('{'))
return {
text: this.characters[id],
route: {
name: 'Character',
params: { character: id },
},
}
else return item
})
},
},
})
</script>
<style scoped></style>
+91
View File
@@ -0,0 +1,91 @@
<template>
<table class="quote-list w-100 px-3">
<tr
v-for="(quote, index) in quotes"
:id="`${sceneIndex}-${index}`"
:key="`quote-${index}`"
:class="
$route.hash !== null && $route.hash.substring(1) === `${sceneIndex}-${index}`
? 'highlight'
: ''
"
>
<td v-if="quote.speaker" class="quote-speaker pl-3">
<DynamicSpeaker
v-if="quote.isAnnotated"
:text="quote.speaker"
:characters="quote.characters"
class="my-3"
/>
<RouterLink
v-else
:to="{ name: 'Character', params: { character: quote.character } }"
class="speaker-link"
>
{{ quote.speaker }}
</RouterLink>
</td>
<td class="quote-text w-100 pr-3" v-html="transform(quote.text)" />
<td class="px-1 pl-2">
<a :href="quote_link(index)" class="no-link" @click="copy(index)">
<!-- <b-icon icon="link45deg" /> -->
</a>
</td>
</tr>
</table>
</template>
<style lang="scss">
@use '@/scss/_variables.scss' as *;
.speaker-bg {
color: $gray-100;
}
.speaker-link {
&,
&:hover {
color: $gray-100;
cursor: pointer;
text-decoration: none;
}
}
</style>
<script lang="ts">
import { defineComponent } from 'vue';
import DynamicSpeaker from '@/components/features/DynamicSpeaker.vue';
export default defineComponent({
components: {
DynamicSpeaker,
},
props: {
sceneIndex: {
required: true,
type: Number,
},
quotes: {
required: true,
type: Array,
},
},
methods: {
transform(quoteText) {
if (quoteText.includes('[')) {
return quoteText.replace(/\[([^\]]+)]/g, ' <i>[$1]</i> ');
}
return quoteText;
},
quote_link(quoteIndex) {
return `/${this.$route.params.season}/${this.$route.params.episode}#${this.sceneIndex}-${quoteIndex}`;
},
copy(quoteIndex) {
this.$copyText(import.meta.env.VUE_APP_BASE_URL + this.quote_link(quoteIndex));
},
},
});
</script>
+156
View File
@@ -0,0 +1,156 @@
<template>
<BCard
class="mb-1"
body-class="p-0 expandable-result"
footer-class="my-1"
:class="[expanded ? 'expanded' : '']"
@mouseover="hoverOn"
@mouseleave="hoverOff"
@click="toggleExpansion"
>
<BCardText class="mu-2 py-1 mb-1">
<table v-if="expanded" class="quote-list px-3 py-1 w-100">
<tr v-for="(quote, index) in above" :key="`quote-a-${index}`" class="secondary">
<td class="quote-speaker my-3 pl-3">
<div>{{ quote.speaker }}</div>
</td>
<td class="quote-text w-100 pr-3">
<div>{{ quote.text }}</div>
</td>
</tr>
<tr>
<td class="quote-speaker my-3 pl-3" v-html="item._highlightResult.speaker.value" />
<td class="quote-text w-100 pr-3" v-html="item._highlightResult.text.value" />
</tr>
<tr v-for="(quote, index) in below" :key="`quote-b-${index}`" class="secondary">
<td class="quote-speaker my-3 pl-3">
<div>{{ quote.speaker }}</div>
</td>
<td class="quote-text w-100 pr-3">
<div>{{ quote.text }}</div>
</td>
</tr>
</table>
<table v-else class="quote-list px-3 py-1 w-100">
<tr>
<td class="quote-speaker my-3 pl-3" v-html="item._highlightResult.speaker.value" />
<td class="quote-text w-100 pr-3" v-html="item._highlightResult.text.value" />
</tr>
</table>
<RouterLink
v-if="expanded"
class="no-link search-result-link w-100 text-muted mb-2 ml-2"
:to="{
name: 'Episode',
params: { season: item.season, episode: item.episode_rel },
hash: `#${item.section_rel - 1}-${item.quote_rel - 1}`,
}"
>
Season {{ item.season }} Episode {{ item.episode_rel }} Scene
{{ item.section_rel }}
</RouterLink>
</BCardText>
</BCard>
</template>
<style lang="scss">
@use '@/scss/_variables.scss' as *;
.expandable-result {
cursor: pointer;
}
.collapse {
display: block;
}
.search-result-link {
white-space: nowrap;
font-size: 0.75em !important;
}
.quote-list > tr {
white-space: nowrap;
&:hover {
background-color: $gray-100;
}
}
.quote-text {
white-space: normal;
}
.quote-speaker {
min-width: 75px;
padding-right: 1em;
font-weight: 600;
vertical-align: text-top;
text-align: right;
font-family: 'Montserrat', sans-serif;
}
</style>
<script lang="ts">
import { defineComponent } from 'vue'
import axios from 'axios'
export default defineComponent({
props: ['item'],
data() {
return {
expanded: false,
fetching: false,
above: null,
below: null,
timeoutID: null,
}
},
computed: {
fetched() {
return this.above !== null || this.below !== null
},
},
methods: {
toggleExpansion() {
this.expanded = !this.expanded
// if first time expanding, fetch quotes
if (!this.fetchQuotes()) {
this.hasExpanded = true
// this.fetchQuotes();
}
},
hoverFetch() {
if (!this.fetched && !this.fetching) {
this.fetching = true
this.fetchQuotes()
this.fetching = false
}
},
hoverOn() {
// Schedule a fetching event
// this.timeoutID = setTimeout(this.hoverFetch, 300);
},
hoverOff() {
// Hover is off. Unschedule event if it has not already fetched.
if (this.timeoutID !== null) clearTimeout(this.timeoutID)
},
fetchQuotes() {
const path = `/api/surrounding?season=${this.item.season}&episode=${this.item.episode_rel}&scene=${this.item.section_rel}&quote=${this.item.quote_rel}`
axios
.get(path)
.then((res) => {
this.above = res.data.above
this.below = res.data.below
})
.catch((error) => {
console.error(error)
})
},
},
})
</script>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="outer-footer">
<footer class="inner-footer">
<BContainer>
<BRow style="text-align: center">
<ul>
<li>
<a href="https://github.com/Xevion/the-office">GitHub</a>
</li>
<li>
<a :href="latestCommitUrl">Latest Commit</a>
</li>
<li>
<a href="https://github.com/Xevion/the-office/issues/new">Report Issues</a>
</li>
<li>
<a href="https://xevion.dev">Xevion.dev</a>
</li>
</ul>
</BRow>
<p v-if="buildTimeString !== null" class="build-time" :title="buildISOString">
built on {{ buildTimeString }}
</p>
</BContainer>
</footer>
</div>
</template>
<script lang="ts">
import { BContainer, BRow } from 'bootstrap-vue-next'
import { defineComponent } from 'vue'
export default defineComponent({
name: 'FooterComponent',
components: {
BContainer,
BRow,
},
props: {
buildMoment: { type: Object, default: null },
},
computed: {
buildTimeString() {
return this.buildMoment.format('MMM Do, YYYY [at] h:mm A zz')
},
buildISOString() {
return this.buildMoment.toISOString()
},
latestCommitUrl() {
return `https://github.com/Xevion/the-office/commit/${import.meta.env.VUE_APP_GIT_HASH}`
},
},
})
</script>
<style lang="scss" scoped>
.outer-footer {
height: 100px;
width: 100%;
position: absolute;
left: 0;
bottom: 0;
}
.inner-footer {
margin: 0 auto;
height: 100%;
color: #6d6d6d;
.build-time {
text-align: center;
padding-top: 0.7em;
opacity: 0.3;
font-size: 0.85em;
margin-bottom: 0;
}
ul {
padding: 0;
list-style: none;
line-height: 1.6;
font-size: 14px;
display: table;
margin: 0 auto;
li {
&:not(:last-child)::after {
padding: 0 0.6em;
content: '|';
}
display: inline;
}
a {
color: inherit;
text-decoration: none;
opacity: 0.6;
&:hover {
opacity: 0.8;
}
}
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ref } from 'vue';
import { SearchIcon } from 'lucide-vue-next';
const searchQuery = ref('');
</script>
<template>
<div class="relative">
<input
v-model="searchQuery"
type="text"
placeholder="Quotes, characters, episodes..."
class="w-72 rounded-lg border border-gray-400 py-2 pr-4 pl-10 outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<SearchIcon class="size-5 text-gray-500" />
</div>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import SeasonListItem from '@/components/layout/SeasonListItem.vue';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { ChevronDown } from 'lucide-vue-next';
import { computed } from 'vue';
import { cn } from '@/lib/utils';
import useStore from '@/store';
const store = useStore();
store.preloadEpisodes();
const seasons = computed(() => store.quoteData);
</script>
<template>
<Accordion type="single" class="font-roboto-slab ml-1 w-[300px]" collapsible>
<AccordionItem
v-for="season in store.quoteData"
:key="season.season_id"
:value="`season-${season.season_id}`"
class="group"
>
<AccordionTrigger
:class="
cn(
'text-foreground/80 cursor-pointer border border-t-transparent bg-white/90 pr-6 pl-5 text-xl group-hover:border-zinc-400 hover:no-underline',
season.season_id !== 9 ? 'border-b-transparent' : 'border-b',
)
"
>
Season {{ season.season_id }}
<template #icon>
<ChevronDown
aria-hidden="true"
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-1.5 transition-transform duration-200"
/>
</template>
</AccordionTrigger>
<AccordionContent
class="text-foreground/80 border-t border-r pb-0 group-hover:border-t-transparent"
>
<template v-for="(episode, index) in seasons[season.season_id - 1].episodes">
<template v-if="'title' in episode">
<SeasonListItem
class="bg-white/90 hover:bg-gray-100"
:key="`rl-${index}`"
:episode-number="episode.episodeNumber"
:season-number="episode.seasonNumber"
:title="episode.title"
/>
</template>
</template>
</AccordionContent>
</AccordionItem>
</Accordion>
</template>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { RouterLink } from 'vue-router';
import { cn } from '@/lib/utils';
import useStore from '@/store';
import { ref } from 'vue';
const store = useStore();
const props = defineProps<
{
episodeNumber: number;
seasonNumber: number;
title: string;
} & { class?: HTMLAttributes['class'] }
>();
const timeoutID = ref<number | null>(null);
const startHover = () => {
timeoutID.value = setTimeout(() => {
store.fetchEpisode({ season: props.seasonNumber, episode: props.episodeNumber });
}, 500);
};
const stopHover = () => {
if (timeoutID.value !== null) {
clearTimeout(timeoutID.value);
timeoutID.value = null;
}
};
</script>
<template>
<RouterLink
tabindex="0"
:aria-label="`Episode ${episodeNumber}: ${title}`"
:id="`s-${seasonNumber}-ep-${episodeNumber}`"
:to="{ name: 'Episode', params: { season: seasonNumber, episode: episodeNumber } }"
:class="
cn(
'group/item focus-visible:ring-ring focus-visible:bg-accent/50 ml-2 flex py-3 pr-3 pl-4 leading-6 transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
props.class,
)
"
@mouseover="startHover"
@mouseleave="stopHover"
>
<span class="text-foreground/50 pr-2 select-none" :aria-hidden="true">{{
episodeNumber.toString().padStart(2, '0')
}}</span>
<span class="text-foreground/80 group-hover/item:text-black">
&OpenCurlyDoubleQuote;{{ title }}&CloseCurlyDoubleQuote;
</span>
</RouterLink>
</template>
<style scoped></style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
AccordionRoot,
type AccordionRootEmits,
type AccordionRootProps,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot data-slot="accordion" v-bind="forwarded">
<slot />
</AccordionRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionContent, type AccordionContentProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
>
<div :class="cn('pt-0 pb-4', props.class)">
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('border-b last:border-b-0', props.class)"
>
<slot />
</AccordionItem>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ChevronDown } from 'lucide-vue-next';
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
import { cn } from '@/lib/utils';
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="props.class"
>
<slot />
</nav>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-item"
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
as: 'a',
})
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { ChevronRight } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Breadcrumb } from './Breadcrumb.vue'
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
export { default as BreadcrumbList } from './BreadcrumbList.vue'
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'