feat(ui): add compact desktop cursor portrait
This commit is contained in:
@@ -7,6 +7,7 @@ import "../globals.css";
|
|||||||
|
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { GoogleAnalytics } from "@/components/google-analytics";
|
import { GoogleAnalytics } from "@/components/google-analytics";
|
||||||
|
import { PoyrazBottomRightFollower } from "@/components/poyraz-bottom-right-follower";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -115,6 +116,7 @@ export default async function LocaleLayout({
|
|||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<GoogleAnalytics />
|
<GoogleAnalytics />
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
|
<PoyrazBottomRightFollower />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,6 +13,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-poyraz-theme="light"] {
|
||||||
|
--poyraz-background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-poyraz-theme="light"] body {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-poyraz-theme="dark"] [data-cursor-portrait] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@keyframes marquee {
|
@keyframes marquee {
|
||||||
0% { transform: translateX(0%); }
|
0% { transform: translateX(0%); }
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
clamp,
|
||||||
|
DEFAULT_TIME,
|
||||||
|
mapPointerYToTime,
|
||||||
|
SMOOTHING,
|
||||||
|
TOTAL_DURATION,
|
||||||
|
} from "@/lib/cursor-portrait";
|
||||||
|
|
||||||
|
const VIDEO_SRC = "/media/cursor-portrait/poyraz-bottom-right.mp4";
|
||||||
|
const POSTER_SRC = "/media/cursor-portrait/poyraz-bottom-right-poster.webp";
|
||||||
|
|
||||||
|
const SEEK_INTERVAL_MS = 1000 / 60;
|
||||||
|
const MIN_TIME_DELTA = 0.002;
|
||||||
|
const MIN_SEEK_DELTA = 1 / 120;
|
||||||
|
|
||||||
|
type DisplayMode =
|
||||||
|
| "pending"
|
||||||
|
| "interactive"
|
||||||
|
| "poster-reduced"
|
||||||
|
| "hidden-mobile"
|
||||||
|
| "hidden-dark";
|
||||||
|
|
||||||
|
function subscribeToMediaQuery(query: MediaQueryList, listener: () => void) {
|
||||||
|
if (typeof query.addEventListener === "function") {
|
||||||
|
query.addEventListener("change", listener);
|
||||||
|
return () => query.removeEventListener("change", listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
query.addListener(listener);
|
||||||
|
return () => query.removeListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PoyrazBottomRightFollower() {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const pointerYRef = useRef<number | null>(null);
|
||||||
|
const targetTimeRef = useRef(DEFAULT_TIME);
|
||||||
|
const currentTimeRef = useRef(DEFAULT_TIME);
|
||||||
|
const rafIdRef = useRef<number | null>(null);
|
||||||
|
const lastSeekTimestampRef = useRef(0);
|
||||||
|
const metadataReadyRef = useRef(false);
|
||||||
|
const primedRef = useRef(false);
|
||||||
|
const [displayMode, setDisplayMode] = useState<DisplayMode>("pending");
|
||||||
|
const [videoFailed, setVideoFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const finePointerQuery = window.matchMedia("(pointer: fine)");
|
||||||
|
const desktopQuery = window.matchMedia("(min-width: 840px)");
|
||||||
|
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
|
||||||
|
const updateDisplayMode = () => {
|
||||||
|
if (document.documentElement.dataset.poyrazTheme === "dark") {
|
||||||
|
setDisplayMode("hidden-dark");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!desktopQuery.matches || !finePointerQuery.matches) {
|
||||||
|
setDisplayMode("hidden-mobile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayMode(reducedMotionQuery.matches ? "poster-reduced" : "interactive");
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDisplayMode();
|
||||||
|
const unsubscribeFinePointer = subscribeToMediaQuery(
|
||||||
|
finePointerQuery,
|
||||||
|
updateDisplayMode,
|
||||||
|
);
|
||||||
|
const unsubscribeDesktop = subscribeToMediaQuery(desktopQuery, updateDisplayMode);
|
||||||
|
const unsubscribeReducedMotion = subscribeToMediaQuery(
|
||||||
|
reducedMotionQuery,
|
||||||
|
updateDisplayMode,
|
||||||
|
);
|
||||||
|
const themeObserver = new MutationObserver(updateDisplayMode);
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-poyraz-theme"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeFinePointer();
|
||||||
|
unsubscribeDesktop();
|
||||||
|
unsubscribeReducedMotion();
|
||||||
|
themeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (displayMode !== "interactive" || videoFailed) return;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const stopAnimationLoop = () => {
|
||||||
|
if (rafIdRef.current === null) return;
|
||||||
|
window.cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekVideo = (timestamp: number) => {
|
||||||
|
if (
|
||||||
|
!metadataReadyRef.current ||
|
||||||
|
video.seeking ||
|
||||||
|
timestamp - lastSeekTimestampRef.current < SEEK_INTERVAL_MS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Number.isFinite(video.duration)
|
||||||
|
? Math.min(video.duration, TOTAL_DURATION)
|
||||||
|
: TOTAL_DURATION;
|
||||||
|
const nextTime = clamp(currentTimeRef.current, 0, duration);
|
||||||
|
|
||||||
|
if (Math.abs(video.currentTime - nextTime) < MIN_SEEK_DELTA) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
video.currentTime = nextTime;
|
||||||
|
lastSeekTimestampRef.current = timestamp;
|
||||||
|
} catch {
|
||||||
|
// The poster remains visible until the browser exposes seekable metadata.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAnimationFrame = (timestamp: number) => {
|
||||||
|
rafIdRef.current = null;
|
||||||
|
if (disposed || document.hidden) return;
|
||||||
|
|
||||||
|
const difference = targetTimeRef.current - currentTimeRef.current;
|
||||||
|
const settled = Math.abs(difference) <= MIN_TIME_DELTA;
|
||||||
|
currentTimeRef.current = settled
|
||||||
|
? targetTimeRef.current
|
||||||
|
: currentTimeRef.current + difference * SMOOTHING;
|
||||||
|
|
||||||
|
seekVideo(timestamp);
|
||||||
|
|
||||||
|
const videoNeedsSeek =
|
||||||
|
metadataReadyRef.current &&
|
||||||
|
(video.seeking ||
|
||||||
|
Math.abs(video.currentTime - targetTimeRef.current) >= MIN_SEEK_DELTA);
|
||||||
|
|
||||||
|
if (!settled || videoNeedsSeek) {
|
||||||
|
rafIdRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAnimationLoop = () => {
|
||||||
|
if (
|
||||||
|
disposed ||
|
||||||
|
document.hidden ||
|
||||||
|
!metadataReadyRef.current ||
|
||||||
|
rafIdRef.current !== null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rafIdRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnToDefault = () => {
|
||||||
|
pointerYRef.current = null;
|
||||||
|
targetTimeRef.current = DEFAULT_TIME;
|
||||||
|
startAnimationLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const primeVideo = () => {
|
||||||
|
if (primedRef.current || !metadataReadyRef.current) return;
|
||||||
|
primedRef.current = true;
|
||||||
|
|
||||||
|
const resumeTime = currentTimeRef.current;
|
||||||
|
video.muted = true;
|
||||||
|
void video
|
||||||
|
.play()
|
||||||
|
.then(() => {
|
||||||
|
if (disposed) return;
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = resumeTime;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
video.pause();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== "mouse") return;
|
||||||
|
|
||||||
|
pointerYRef.current = event.clientY;
|
||||||
|
targetTimeRef.current = mapPointerYToTime(event.clientY, window.innerHeight);
|
||||||
|
primeVideo();
|
||||||
|
startAnimationLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (pointerYRef.current === null) return;
|
||||||
|
targetTimeRef.current = mapPointerYToTime(
|
||||||
|
pointerYRef.current,
|
||||||
|
window.innerHeight,
|
||||||
|
);
|
||||||
|
startAnimationLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopAnimationLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startAnimationLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
video.pause();
|
||||||
|
metadataReadyRef.current = true;
|
||||||
|
targetTimeRef.current = DEFAULT_TIME;
|
||||||
|
currentTimeRef.current = DEFAULT_TIME;
|
||||||
|
video.currentTime = clamp(DEFAULT_TIME, 0, video.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||||
|
handleLoadedMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||||
|
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||||
|
document.documentElement.addEventListener("pointerleave", returnToDefault);
|
||||||
|
window.addEventListener("blur", returnToDefault);
|
||||||
|
window.addEventListener("resize", handleResize, { passive: true });
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
stopAnimationLoop();
|
||||||
|
video.pause();
|
||||||
|
metadataReadyRef.current = false;
|
||||||
|
primedRef.current = false;
|
||||||
|
pointerYRef.current = null;
|
||||||
|
targetTimeRef.current = DEFAULT_TIME;
|
||||||
|
currentTimeRef.current = DEFAULT_TIME;
|
||||||
|
lastSeekTimestampRef.current = 0;
|
||||||
|
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
document.documentElement.removeEventListener("pointerleave", returnToDefault);
|
||||||
|
window.removeEventListener("blur", returnToDefault);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [displayMode, videoFailed]);
|
||||||
|
|
||||||
|
const showVideo = displayMode === "interactive" && !videoFailed;
|
||||||
|
|
||||||
|
if (
|
||||||
|
displayMode === "pending" ||
|
||||||
|
displayMode === "hidden-mobile" ||
|
||||||
|
displayMode === "hidden-dark"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-cursor-portrait
|
||||||
|
className="pointer-events-none fixed right-6 bottom-0 z-40 aspect-square w-[clamp(110px,11vw,170px)] select-none bg-white"
|
||||||
|
>
|
||||||
|
{showVideo ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={VIDEO_SRC}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
poster={POSTER_SRC}
|
||||||
|
draggable={false}
|
||||||
|
disablePictureInPicture
|
||||||
|
className="h-full w-full bg-white object-contain"
|
||||||
|
onError={() => setVideoFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={POSTER_SRC}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1545px) 11vw, 170px"
|
||||||
|
draggable={false}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const TOTAL_DURATION = 4;
|
||||||
|
export const ACTIVE_START = 0.25;
|
||||||
|
export const ACTIVE_END = 3.75;
|
||||||
|
export const DEFAULT_TIME = 2;
|
||||||
|
export const SMOOTHING = 0.12;
|
||||||
|
|
||||||
|
export function clamp(value: number, minimum: number, maximum: number) {
|
||||||
|
return Math.min(Math.max(value, minimum), maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPointerYToTime(pointerY: number, viewportHeight: number) {
|
||||||
|
if (viewportHeight <= 0) return DEFAULT_TIME;
|
||||||
|
|
||||||
|
const progress = clamp(1 - pointerY / viewportHeight, 0, 1);
|
||||||
|
return ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user