v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -4,6 +4,15 @@ import { AuthContext } from "../contexts/AuthContext.ts";
import { type AuthResponse } from "../model.ts";
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
export const useAuth = () => {
const { authResponse, setAuthResponse } = useContext(AuthContext);
@@ -19,6 +28,13 @@ export const useAuth = () => {
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
// Return a synthetic 401 so callers handle it consistently
return new Response(null, { status: 401 });
}
const isFormData = init.body instanceof FormData;
const res = await fetch(input, {

56
src/hooks/useFeedCache.ts Normal file
View File

@@ -0,0 +1,56 @@
import { useCallback, useMemo } from "react";
import { useNavigationType } from "react-router";
const TTL = 10 * 60 * 1000; // 10 minutes
interface FeedCacheEntry<T> {
items: T[];
page: number;
hasMore: boolean;
scrollY: number;
savedAt: number;
}
export interface FeedCacheResult<T> {
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
saveState: (items: T[], page: number, hasMore: boolean, scrollY: number) => void;
}
export function useFeedCache<T>(
key: string,
hydrateItem: (raw: T) => T,
): FeedCacheResult<T> {
const navType = useNavigationType();
// Read ONCE on mount. Empty deps is intentional — nav type and cache are only
// relevant at the moment the component first mounts.
const cached = useMemo<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
if (navType !== "POP") return null;
try {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw) as FeedCacheEntry<T>;
if (Date.now() - entry.savedAt > TTL) return null;
return { ...entry, items: entry.items.map(hydrateItem) };
} catch {
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const saveState = useCallback(
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
try {
sessionStorage.setItem(
key,
JSON.stringify({ items, page, hasMore, scrollY, savedAt: Date.now() }),
);
} catch {
// QuotaExceededError or SecurityError — degrade silently
}
},
[key],
);
return { cached, saveState };
}

View File

@@ -0,0 +1,26 @@
import { useCallback, useEffect, useRef } from "react";
export function useInfiniteScroll(onLoadMore: () => void, enabled: boolean) {
const sentinelRef = useRef<HTMLDivElement>(null);
const handleIntersect = useCallback(
(entries: IntersectionObserverEntry[]) => {
if (entries[0].isIntersecting && enabled) {
onLoadMore();
}
},
[onLoadMore, enabled],
);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(handleIntersect, {
rootMargin: "200px",
});
observer.observe(el);
return () => observer.disconnect();
}, [handleIntersect]);
return sentinelRef;
}