feat: add continue watching carousel
This commit is contained in:
@@ -11,5 +11,6 @@ import "./dedupe";
|
||||
import "./shell";
|
||||
import "./watchlist";
|
||||
import "./top_pick_carousel";
|
||||
import "./continue_watching_carousel";
|
||||
import "./login";
|
||||
import "./schedule";
|
||||
|
||||
170
static/continue_watching_carousel.ts
Normal file
170
static/continue_watching_carousel.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { onHtmxLoad, onReady } from "./utils";
|
||||
|
||||
const carouselScrollEpsilon = 2;
|
||||
const fallbackCarouselOverlap = 96;
|
||||
const itemOverlapRatio = 0.45;
|
||||
const minimumArrowItems = 5;
|
||||
|
||||
type ContinueWatchingCarousel = {
|
||||
track: HTMLElement;
|
||||
previous: HTMLButtonElement;
|
||||
next: HTMLButtonElement;
|
||||
previousFade: HTMLElement;
|
||||
nextFade: HTMLElement;
|
||||
};
|
||||
|
||||
const getContinueWatchingCarousel = (
|
||||
root: HTMLElement,
|
||||
): ContinueWatchingCarousel | null => {
|
||||
const track = root.querySelector<HTMLElement>("[data-continue-watching-track]");
|
||||
const previous = root.querySelector<HTMLButtonElement>(
|
||||
"[data-continue-watching-prev]",
|
||||
);
|
||||
const next = root.querySelector<HTMLButtonElement>("[data-continue-watching-next]");
|
||||
const previousFade = root.querySelector<HTMLElement>(
|
||||
"[data-continue-watching-prev-fade]",
|
||||
);
|
||||
const nextFade = root.querySelector<HTMLElement>("[data-continue-watching-next-fade]");
|
||||
|
||||
if (!track || !previous || !next || !previousFade || !nextFade) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { track, previous, next, previousFade, nextFade };
|
||||
};
|
||||
|
||||
const continueWatchingCarousels = (root: ParentNode = document): HTMLElement[] =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>("[data-continue-watching-carousel]"));
|
||||
|
||||
const maxScrollLeft = (track: HTMLElement): number =>
|
||||
Math.max(0, track.scrollWidth - track.clientWidth);
|
||||
|
||||
const setControlState = (
|
||||
button: HTMLButtonElement,
|
||||
fade: HTMLElement,
|
||||
visible: boolean,
|
||||
): void => {
|
||||
button.classList.toggle("hidden", !visible);
|
||||
button.classList.toggle("inline-flex", visible);
|
||||
button.setAttribute("aria-hidden", String(!visible));
|
||||
button.tabIndex = visible ? 0 : -1;
|
||||
fade.classList.toggle("hidden", !visible);
|
||||
};
|
||||
|
||||
const updateContinueWatchingCarousel = (root: HTMLElement): void => {
|
||||
const carousel = getContinueWatchingCarousel(root);
|
||||
if (!carousel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = carousel.track.querySelectorAll("[data-continue-watching-item]");
|
||||
const maxScroll = maxScrollLeft(carousel.track);
|
||||
const canScroll = maxScroll > carouselScrollEpsilon;
|
||||
const allowArrows = canScroll && items.length >= minimumArrowItems;
|
||||
const hasPrevious =
|
||||
allowArrows && carousel.track.scrollLeft > carouselScrollEpsilon;
|
||||
const hasNext =
|
||||
allowArrows && carousel.track.scrollLeft < maxScroll - carouselScrollEpsilon;
|
||||
|
||||
setControlState(carousel.previous, carousel.previousFade, hasPrevious);
|
||||
setControlState(carousel.next, carousel.nextFade, hasNext);
|
||||
};
|
||||
|
||||
const updateContinueWatchingCarousels = (root: ParentNode = document): void => {
|
||||
continueWatchingCarousels(root).forEach(updateContinueWatchingCarousel);
|
||||
};
|
||||
|
||||
const carouselScrollAmount = (track: HTMLElement): number => {
|
||||
const firstItem = track.querySelector<HTMLElement>("[data-continue-watching-item]");
|
||||
if (!firstItem) {
|
||||
return Math.max(160, track.clientWidth - fallbackCarouselOverlap);
|
||||
}
|
||||
|
||||
const itemWidth = firstItem.getBoundingClientRect().width;
|
||||
const overlap = Math.max(fallbackCarouselOverlap, itemWidth * itemOverlapRatio);
|
||||
|
||||
return Math.max(itemWidth, track.clientWidth - Math.min(itemWidth, overlap));
|
||||
};
|
||||
|
||||
const scrollContinueWatchingCarousel = (
|
||||
root: HTMLElement,
|
||||
direction: -1 | 1,
|
||||
): void => {
|
||||
const carousel = getContinueWatchingCarousel(root);
|
||||
if (!carousel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScroll = carousel.track.scrollLeft;
|
||||
const targetScroll =
|
||||
direction < 0
|
||||
? Math.max(0, currentScroll - carouselScrollAmount(carousel.track))
|
||||
: Math.min(
|
||||
maxScrollLeft(carousel.track),
|
||||
currentScroll + carouselScrollAmount(carousel.track),
|
||||
);
|
||||
|
||||
carousel.track.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
window.setTimeout(() => updateContinueWatchingCarousel(root), 350);
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event: MouseEvent): void => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = target.closest("[data-continue-watching-prev]");
|
||||
if (previous) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const root = previous.closest<HTMLElement>("[data-continue-watching-carousel]");
|
||||
if (root) {
|
||||
scrollContinueWatchingCarousel(root, -1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = target.closest("[data-continue-watching-next]");
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const root = next.closest<HTMLElement>("[data-continue-watching-carousel]");
|
||||
if (root) {
|
||||
scrollContinueWatchingCarousel(root, 1);
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
"scroll",
|
||||
(event: Event): void => {
|
||||
const target = event.target;
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
!target.matches("[data-continue-watching-track]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = target.closest<HTMLElement>("[data-continue-watching-carousel]");
|
||||
if (root) {
|
||||
updateContinueWatchingCarousel(root);
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
onReady(() => updateContinueWatchingCarousels());
|
||||
onHtmxLoad((root) => updateContinueWatchingCarousels(root));
|
||||
window.addEventListener("resize", () => updateContinueWatchingCarousels());
|
||||
@@ -1,13 +1,14 @@
|
||||
{{define "continue_watching"}}
|
||||
<section id="continue-watching-section" class="w-full empty:hidden">
|
||||
<section id="continue-watching-section" class="w-full empty:hidden" data-continue-watching-carousel>
|
||||
<h2 class="mb-3 text-base font-normal text-foreground">Continue Watching</h2>
|
||||
|
||||
<div id="continue-watching-items" class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:[-ms-overflow-style:auto] lg:[scrollbar-width:thin] lg:[scrollbar-color:var(--scrollbar-thumb)_var(--scrollbar-track)] lg:[&::-webkit-scrollbar]:block lg:[&::-webkit-scrollbar]:h-2 lg:[&::-webkit-scrollbar-track]:bg-[var(--scrollbar-track)] lg:[&::-webkit-scrollbar-track]:rounded-none lg:[&::-webkit-scrollbar-thumb]:bg-[var(--scrollbar-thumb)] lg:[&::-webkit-scrollbar-thumb]:rounded-none lg:[&::-webkit-scrollbar-thumb:hover]:bg-[var(--scrollbar-thumb-hover)]">
|
||||
<div class="relative overflow-hidden">
|
||||
<div id="continue-watching-items" data-continue-watching-track class="flex snap-x snap-mandatory gap-2 overflow-x-auto scroll-smooth pb-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{{range .}}
|
||||
{{$title := .TitleOriginal}}
|
||||
{{if .TitleEnglish.Valid}}{{$title = .TitleEnglish.String}}{{end}}
|
||||
|
||||
<div id="continue-watching-{{.AnimeID}}" class="continue-watching-item group relative w-70 shrink-0 snap-start space-y-2 2xl:w-lg">
|
||||
<div id="continue-watching-{{.AnimeID}}" data-continue-watching-item class="continue-watching-item group relative w-70 shrink-0 snap-start space-y-2 2xl:w-lg">
|
||||
<div class="bg-background/80 relative aspect-video w-full overflow-hidden">
|
||||
<a href="/anime/{{.AnimeID}}/watch{{if .CurrentEpisode.Valid}}?ep={{.CurrentEpisode.Int64}}{{end}}" class="block h-full w-full outline-none ring-0 transition focus-visible:ring-1 focus-visible:ring-accent">
|
||||
<img src="{{if .ImageUrl}}{{.ImageUrl}}{{else}}https://placehold.co/500x500{{end}}" alt="{{$title}}" class="h-full w-full object-cover" />
|
||||
@@ -41,5 +42,14 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div data-continue-watching-prev-fade class="pointer-events-none absolute inset-y-0 left-0 z-[70] hidden w-20 bg-linear-to-r from-background via-background/80 to-transparent" aria-hidden="true"></div>
|
||||
<div data-continue-watching-next-fade class="pointer-events-none absolute inset-y-0 right-0 z-[70] hidden w-20 bg-linear-to-l from-background via-background/80 to-transparent" aria-hidden="true"></div>
|
||||
<button type="button" data-unstyled-button data-continue-watching-prev class="pointer-events-auto absolute left-0 top-[42%] z-[80] hidden h-16 w-14 -translate-y-1/2 cursor-pointer items-center justify-center text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" aria-label="Scroll continue watching left" aria-hidden="true" tabindex="-1">
|
||||
<svg class="pointer-events-auto size-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
||||
</button>
|
||||
<button type="button" data-unstyled-button data-continue-watching-next class="pointer-events-auto absolute right-0 top-[42%] z-[80] hidden h-16 w-14 -translate-y-1/2 cursor-pointer items-center justify-center text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" aria-label="Scroll continue watching right" aria-hidden="true" tabindex="-1">
|
||||
<svg class="pointer-events-auto size-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -117,16 +117,26 @@
|
||||
{{end}}
|
||||
|
||||
{{define "continue_watching_skeleton"}}
|
||||
<section id="continue-watching-section" class="w-full">
|
||||
<section id="continue-watching-section" class="w-full" data-continue-watching-carousel>
|
||||
<h2 class="mb-3 text-base font-normal text-foreground">Continue Watching</h2>
|
||||
<div class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:[-ms-overflow-style:auto] lg:[scrollbar-width:thin] lg:[scrollbar-color:var(--scrollbar-thumb)_var(--scrollbar-track)] lg:[&::-webkit-scrollbar]:block lg:[&::-webkit-scrollbar]:h-2 lg:[&::-webkit-scrollbar-track]:bg-[var(--scrollbar-track)] lg:[&::-webkit-scrollbar-track]:rounded-none lg:[&::-webkit-scrollbar-thumb]:bg-[var(--scrollbar-thumb)] lg:[&::-webkit-scrollbar-thumb]:rounded-none lg:[&::-webkit-scrollbar-thumb:hover]:bg-[var(--scrollbar-thumb-hover)]">
|
||||
<div class="relative overflow-hidden">
|
||||
<div data-continue-watching-track class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{{range (seq 3)}}
|
||||
<div class="flex w-70 shrink-0 snap-start flex-col gap-2 2xl:w-lg">
|
||||
<div data-continue-watching-item class="flex w-70 shrink-0 snap-start flex-col gap-2 2xl:w-lg">
|
||||
<div class="skeleton aspect-video w-full overflow-hidden"></div>
|
||||
<div class="skeleton h-5 w-2/3"></div>
|
||||
<div class="skeleton skeleton-subtle h-4 w-1/2"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div data-continue-watching-prev-fade class="pointer-events-none absolute inset-y-0 left-0 z-[70] hidden w-20 bg-linear-to-r from-background via-background/80 to-transparent" aria-hidden="true"></div>
|
||||
<div data-continue-watching-next-fade class="pointer-events-none absolute inset-y-0 right-0 z-[70] hidden w-20 bg-linear-to-l from-background via-background/80 to-transparent" aria-hidden="true"></div>
|
||||
<button type="button" data-unstyled-button data-continue-watching-prev class="pointer-events-auto absolute left-0 top-[42%] z-[80] hidden h-16 w-14 -translate-y-1/2 cursor-pointer items-center justify-center text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" aria-label="Scroll continue watching left" aria-hidden="true" tabindex="-1">
|
||||
<svg class="pointer-events-auto size-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
||||
</button>
|
||||
<button type="button" data-unstyled-button data-continue-watching-next class="pointer-events-auto absolute right-0 top-[42%] z-[80] hidden h-16 w-14 -translate-y-1/2 cursor-pointer items-center justify-center text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" aria-label="Scroll continue watching right" aria-hidden="true" tabindex="-1">
|
||||
<svg class="pointer-events-auto size-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user