mirror of
https://github.com/Xevion/the-office.git
synced 2026-01-31 10:26:21 -06:00
feat!: switch to Nuxt, complete overhaul
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}"e=${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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
“{{ title }}”
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user