Skip to content

Commit ee74ee2

Browse files
committed
✨ Add episode detail view with image retrieval
1 parent 0e78266 commit ee74ee2

File tree

6 files changed

+161
-13
lines changed

6 files changed

+161
-13
lines changed

components/items/EpisodeItem.vue

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,32 @@
22
import Image from "~/components/misc/Image.vue";
33
44
const props = defineProps<{
5-
episode: Episode
5+
episode: EpisodeWithProgression
66
}>();
77
</script>
88

99
<template>
10-
<NuxtLink class="flex h-20 border border-t-0 hover:cursor-pointer hover:bg-secondary md:h-24 lg:h-28">
11-
<Image :sum="episode.thumbnail"/>
10+
<NuxtLink
11+
class="flex h-20 border border-t-0 transition-colors hover:cursor-pointer md:h-24 lg:h-28"
12+
:class="[
13+
(episode.progression || 0) > 0 ? 'bg-amber-100/30' : 'hover:bg-secondary',
14+
]"
15+
:to="'/episode/' + episode.id"
16+
>
17+
<Image :sum="episode.thumbnail" />
1218
<div class="flex grow items-center justify-center p-2">
1319
<div class="flex grow items-center gap-2 md:w-[25rem] lg:w-[35rem]">
14-
<UiBadge v-if="props.episode.isNew" variant="default" class="h-max">New</UiBadge>
15-
<h4 class="truncate">{{episode.title}}</h4>
20+
<UiBadge
21+
v-if="props.episode.isNew"
22+
variant="default"
23+
class="h-max"
24+
>
25+
New
26+
</UiBadge>
27+
<h4 class="truncate">{{ episode.title }}</h4>
1628
</div>
1729
<div>
18-
<p class="opacity-50">#{{episode.number}}</p>
30+
<p class="opacity-50">#{{ episode.number }}</p>
1931
</div>
2032
</div>
2133
</NuxtLink>

components/misc/Image.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script setup lang="ts">
22
const props = defineProps<{
3-
sum: string
3+
sum: string,
4+
width?: number,
5+
height?: number,
46
}>();
57
68
const serverUrl = useLocalStorage("serverUrl", "");
@@ -16,8 +18,8 @@ const thumbnails = computed(() => {
1618
</script>
1719

1820
<template>
19-
<NuxtImg v-if="!fallback" :src="thumbnails[0]" loading="lazy" format="webp" class="aspect-square h-full" @error="fallback = true"/>
20-
<NuxtImg v-else :src="thumbnails[1]" loading="lazy" format="webp" class="aspect-square h-full"/>
21+
<NuxtImg v-if="!fallback" :src="thumbnails[0]" loading="lazy" format="webp" class="h-full" :height :width @error="fallback = true"/>
22+
<NuxtImg v-else :src="thumbnails[1]" loading="lazy" format="webp" class="h-full" :height :width/>
2123
</template>
2224

2325
<style scoped>

nuxt.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ export default defineNuxtConfig({
3636
type: true,
3737
}],
3838
},
39-
});
39+
});

pages/episode/[id].vue

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<script setup lang="ts">
2+
import Image from "~/components/misc/Image.vue";
3+
4+
const id = useRoute().params.id;
5+
const serverUrl = useLocalStorage("serverUrl", "");
6+
const router = useRouter();
7+
8+
const displayCount = ref(10);
9+
const sentinel = ref<HTMLElement | null>(null);
10+
let observer: IntersectionObserver;
11+
12+
const {data: episode} = await useAsyncData<EpisodeWithProgression>(
13+
`episode-${id}`,
14+
() => $fetch(`${serverUrl.value}/webtoons/episodes/${id}`), {server: false});
15+
16+
const {data: images} = await useAsyncData<string[]>(
17+
`episode-images-${id}`,
18+
() => $fetch(`${serverUrl.value}/webtoons/episodes/${id}/images`), {server: false});
19+
20+
const displayedImages = computed(() => {
21+
return images.value?.slice(0, displayCount.value) || [];
22+
});
23+
24+
const hasMore = computed(() => {
25+
return images.value && displayCount.value < images.value.length;
26+
});
27+
28+
onMounted(() => {
29+
observer = new IntersectionObserver((entries) => {
30+
if (entries[0].isIntersecting && hasMore.value){
31+
displayCount.value += 10;
32+
}
33+
}, {
34+
root: document.querySelector(".flex-1"),
35+
rootMargin: "100px 0px"
36+
});
37+
38+
watchEffect(() => {
39+
if (sentinel.value && hasMore.value){
40+
observer.observe(sentinel.value);
41+
} else if (sentinel.value){
42+
observer.unobserve(sentinel.value);
43+
}
44+
});
45+
46+
onBeforeUnmount(() => {
47+
observer.disconnect();
48+
});
49+
});
50+
</script>
51+
52+
<template>
53+
<div class="flex w-full grow flex-col items-center">
54+
<div class="flex w-full items-center justify-between p-4 shadow-md">
55+
<NuxtLink class="flex items-center gap-2" @click="router.back">
56+
<UiButton variant="ghost" size="icon">
57+
<Icon name="iconoir:arrow-left" />
58+
</UiButton>
59+
<h1 class="text-xl font-semibold">{{ episode?.title }}</h1>
60+
</NuxtLink>
61+
</div>
62+
<div class="flex w-full flex-col md:w-2/3 lg:w-1/2 xl:w-1/3">
63+
<div v-for="(image, index) in (displayedImages || [])" :key="index" class="w-full">
64+
<Image
65+
:sum="image"
66+
format="webp"
67+
alt="Episode Image"
68+
class="w-full"
69+
:width="800"
70+
:height="1280"
71+
/>
72+
</div>
73+
<div
74+
v-show="hasMore"
75+
ref="sentinel"
76+
class="h-2 w-full opacity-0"
77+
/>
78+
</div>
79+
</div>
80+
</template>

pages/webtoon/[id].vue

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import EpisodeItem from "~/components/items/EpisodeItem.vue";
3+
import type {EpisodeWithProgression} from "~/utils/types";
34
45
definePageMeta({
56
layout: "navigation",
@@ -10,17 +11,31 @@ definePageMeta({
1011
1112
const id = useRoute().params.id;
1213
const serverUrl = useLocalStorage("serverUrl", "");
13-
const {data: webtoon} = await useAsyncData<Webtoon>("webtoon", () => $fetch(`${serverUrl.value}/webtoons/${id}`), {
14+
const {data: webtoon} = await useAsyncData<Webtoon>(`webtoon-${id}`, () => $fetch(`${serverUrl.value}/webtoons/${id}`), {
1415
server: false,
1516
});
16-
const {data: episodes} = await useAsyncData<Episode[]>("episodes", () => $fetch(`${serverUrl.value}/webtoons/${id}/episodes`), {
17+
const {data: episodes} = await useAsyncData<EpisodeWithProgression[]>(`episodes-${id}`, () => $fetch(`${serverUrl.value}/webtoons/${id}/episodes`), {
1718
server: false,
1819
});
20+
const token = useCookie("token").value;
21+
const {data: progressions} = await useAsyncData<Progression[]>(`progressions-${id}`, async() => {
22+
if(!token) return [];
23+
try{
24+
return await $fetch(`${serverUrl.value}/user/progression/webtoon/${id}`, {
25+
headers: {Authorization: `Bearer ${token}`}
26+
});
27+
}catch{
28+
return [];
29+
}
30+
}, {server: false});
1931
20-
const displayCount = ref(50);
32+
const displayCount = ref(15);
2133
const displayedEpisodes = computed(() => {
2234
if(!episodes.value)
2335
return [];
36+
episodes.value.forEach((episode) => {
37+
episode.progression = progressions.value?.find((progression) => progression.episodeId === episode.id)?.progression ?? 0;
38+
});
2439
if(isIncreasing.value)
2540
return episodes.value.slice(-displayCount.value).reverse();
2641
return episodes.value.slice(0, displayCount.value);
@@ -35,8 +50,37 @@ function toggleIncreasing(){
3550
function resume(){
3651
// TODO: Implement resume
3752
}
53+
54+
const hasMore = computed(() => {
55+
return episodes.value && displayCount.value < episodes.value.length;
56+
});
57+
58+
const sentinel = ref<HTMLElement | null>(null);
59+
let observer: IntersectionObserver;
60+
61+
onMounted(() => {
62+
observer = new IntersectionObserver((entries) => {
63+
if(entries[0].isIntersecting && hasMore.value)
64+
displayCount.value += 15;
65+
}, {
66+
root: document.querySelector(".h-dvh"),
67+
rootMargin: "0px 0px 100px 0px"
68+
});
69+
70+
watchEffect(() => {
71+
if(sentinel.value && hasMore.value)
72+
observer.observe(sentinel.value);
73+
else if (sentinel.value)
74+
observer.unobserve(sentinel.value);
75+
});
76+
77+
onBeforeUnmount(() => {
78+
observer.disconnect();
79+
});
80+
});
3881
</script>
3982

83+
4084
<template>
4185
<UiScrollArea class="h-dvh">
4286
<div class="flex flex-col">
@@ -54,6 +98,7 @@ function resume(){
5498
</div>
5599
</div>
56100
<EpisodeItem v-for="(episode, i) in displayedEpisodes" :key="i" :episode="episode"/>
101+
<div v-if="hasMore" ref="sentinel" class="h-1 w-full"/>
57102
</div>
58103
</div>
59104
</UiScrollArea>

utils/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,12 @@ export interface Episode{
1616
isNew: boolean
1717
thumbnail: string;
1818
}
19+
20+
export interface EpisodeWithProgression extends Episode{
21+
progression?: number;
22+
}
23+
24+
export interface Progression{
25+
episodeId: string;
26+
progression: number;
27+
}

0 commit comments

Comments
 (0)