feat: slugify, switch episode route to title-based url

This commit is contained in:
2025-07-16 19:30:38 -05:00
parent ae445de3b6
commit e7da1423ce
4 changed files with 81 additions and 49 deletions
+41 -21
View File
@@ -8,31 +8,54 @@ import {
} 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);
import episodes from '@/../public/json/episodes.json';
const data = computed(() => {
return episodes.map((season, seasonIndex) => {
return {
seasonNumber: seasonIndex + 1,
episodes: season.map((episode, episodeIndex) => {
const title = episode?.title;
if (!title) {
// throw new Error(
// `Episode title is required for season ${seasonIndex + 1} episode ${episodeIndex + 1}`,
// );
return {
episodeNumber: episodeIndex + 1,
seasonNumber: seasonIndex + 1,
title: 'IDK',
};
}
return {
episodeNumber: episodeIndex + 1,
seasonNumber: seasonIndex + 1,
title,
};
}),
};
});
});
</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}`"
v-for="season in data"
:key="season.seasonNumber"
:value="`season-${season.seasonNumber}`"
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.seasonNumber !== 9 ? 'border-b-transparent' : 'border-b',
)
"
>
Season {{ season.season_id }}
Season {{ season.seasonNumber }}
<template #icon>
<ChevronDown
aria-hidden="true"
@@ -43,17 +66,14 @@ const seasons = computed(() => store.quoteData);
<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
:key="`rl-${index}`"
class="bg-white/90 hover:bg-gray-100"
:episode-number="episode.episodeNumber"
:season-number="episode.seasonNumber"
:title="episode.title"
/>
</template>
</template>
<SeasonListItem
v-for="episode in season.episodes"
:key="`episode-${episode.title}`"
class="bg-white/90 hover:bg-gray-100"
:episode-number="episode.episodeNumber"
:season-number="episode.seasonNumber"
:title="episode.title"
/>
</AccordionContent>
</AccordionItem>
</Accordion>
+14 -20
View File
@@ -1,10 +1,6 @@
<script setup lang="ts">
import { slugify } from '@/lib/utils';
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import useStore from '@/store';
import { ref } from 'vue';
const store = useStore();
const props = defineProps<
{
@@ -14,20 +10,20 @@ const props = defineProps<
} & { class?: HTMLAttributes['class'] }
>();
const timeoutID = ref<ReturnType<typeof setTimeout> | null>(null);
// const timeoutID = ref<ReturnType<typeof setTimeout> | null>(null);
const startHover = () => {
timeoutID.value = setTimeout(() => {
store.fetchEpisode({ season: props.seasonNumber, episode: props.episodeNumber });
}, 500);
};
// 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;
}
};
// const stopHover = () => {
// if (timeoutID.value !== null) {
// clearTimeout(timeoutID.value);
// timeoutID.value = null;
// }
// };
</script>
<template>
@@ -35,15 +31,13 @@ const stopHover = () => {
:id="`s-${seasonNumber}-ep-${episodeNumber}`"
tabindex="0"
:aria-label="`Episode ${episodeNumber}: ${title}`"
:to="{ name: 'Episode', params: { season: seasonNumber, episode: episodeNumber } }"
:to="`/episode/${slugify(title)}`"
: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')
+9 -1
View File
@@ -1,6 +1,14 @@
import { type ClassValue, clsx } from 'clsx';
import { clsx } from 'clsx';
import type { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function slugify(text: string) {
return text
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
}
+17 -7
View File
@@ -1,21 +1,31 @@
<script setup lang="ts">
import QuoteList from '@/components/features/QuoteList.vue';
// import QuoteList from '@/components/features/QuoteList.vue';
// import CharacterBadges from '@/components/features/CharacterBadges.vue';
import Skeleton from '@/components/common/Skeleton.vue';
// import Skeleton from '@/components/common/Skeleton.vue';
import Breadcrumb from '@/components/common/Breadcrumb.vue';
import { computed } from 'vue';
const route = useRoute();
import episodes from '@/../public/json/episodes.json';
import { slugify } from '@/lib/utils';
const params = { season: 1, episode: route.params.id };
const { params } = useRoute();
const episodeSlug = params.id;
const episode = computed(() => {
return (
episodes
.flatMap((season) => season.map((episode) => episode))
.find((episode) => slugify(episode?.title ?? '') === episodeSlug) ?? null
);
});
const breadcrumbs = computed(() => {
return [
{ text: 'Home', to: '/' },
{ text: `Season ${params.season}`, to: `/season/${params.season}` },
{ text: `Season ${episode.value?.seasonNumber}`, to: `/season/${episode.value?.seasonNumber}` },
{
text: `Episode ${params.episode}`,
to: `/episode/${params.episode}`,
text: `Episode ${episode.value?.episodeNumber}`,
to: `/episode/${episode.value?.episodeNumber}`,
},
];
});