Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions api/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { createApp } from "../server/app";
import type { IncomingMessage, ServerResponse } from "node:http";
import { createApp } from "../server/app.js";

// Vercel serverless entry. The whole Express app is mounted as a single
// function; vercel.json rewrites /api/* here. Build the app once and reuse it
// across warm invocations.
const appPromise = createApp();

export default async function handler(req: VercelRequest, res: VercelResponse) {
export default async function handler(req: IncomingMessage, res: ServerResponse) {
const app = await appPromise;
return (app as unknown as (req: VercelRequest, res: VercelResponse) => void)(req, res);
return app(req, res);
}
51 changes: 36 additions & 15 deletions client/src/hooks/use-video-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { useQuery } from '@tanstack/react-query';
import { useStore } from '@/lib/store';
import { searchVideos } from '@/lib/archive-api';

// Single owner of the search query. Every component that needs search data uses
// this hook with the same query key, so React Query dedupes to one request and
// one component tree can't cancel another's fetch.
export function useVideoSearch() {
interface UseVideoSearchOptions {
syncToStore?: boolean;
}

// Every observer uses the same query key, so React Query dedupes requests. Only
// one observer should sync the shared query back into Zustand; otherwise each
// mounted ResultsGrid appends the same page again.
export function useVideoSearch({ syncToStore = false }: UseVideoSearchOptions = {}) {
const { searchState, setSearchResults, setTotalResults, setLoading } = useStore();
const requestedPage = searchState.page || 1;

const query = useQuery({
queryKey: [
Expand All @@ -34,27 +39,43 @@ export function useVideoSearch() {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});

const { data, error } = query;
const { data, error, isFetching } = query;

// Keep store-backed loading UI in step with React Query for the full request.
// Reset on teardown so the global flag can't stay stuck at true if the
// syncing observer unmounts while a request is in flight.
useEffect(() => {
if (!syncToStore) return;
setLoading(isFetching);
return () => setLoading(false);
}, [isFetching, setLoading, syncToStore]);
Comment thread
drftstatic marked this conversation as resolved.

// Sync results into the store: replace on page 1, append on later pages
// Sync results into the store: replace on page 1, append on later pages.
// The server only returns docs with identifiers, but cached or hand-edited
// payloads may not honor that, so drop identifier-less entries before keying.
useEffect(() => {
if (!data) return;
const docs = (data as any).docs || [];
if ((searchState.page || 1) > 1) {
setSearchResults([...useStore.getState().searchResults, ...docs]);
if (!syncToStore || !data) return;
const docs = (((data as any).docs || []) as any[]).filter((video) => video?.identifier);
if (requestedPage > 1) {
const resultsByIdentifier = new Map(
useStore
.getState()
.searchResults.filter((video) => video?.identifier)
.map((video) => [video.identifier, video]),
);
docs.forEach((video: any) => resultsByIdentifier.set(video.identifier, video));
setSearchResults(Array.from(resultsByIdentifier.values()));
} else {
setSearchResults(docs);
}
setTotalResults((data as any).numFound || 0);
setLoading(false);
}, [data]);
}, [data, requestedPage, setSearchResults, setTotalResults, syncToStore]);

useEffect(() => {
if (error) {
setLoading(false);
if (syncToStore && error) {
console.error('Search error:', error);
}
}, [error]);
}, [error, syncToStore]);

return query;
}
6 changes: 3 additions & 3 deletions client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@
#333333 60%, /* gray */
#ffffff 80%, /* white */
#000000 100%); /* back to black */
};
}

/* 5. D-GENERATION X - Attitude Era Green & Black */
.dx-gradient {
Expand All @@ -314,7 +314,7 @@
#22c55e 60%, /* DX green */
#16a34a 80%, /* forest green */
#15803d 100%); /* deep green */
};
}

/* 6. MAX HEADROOM - 80s TV Digital Glitch */
.maxheadroom-gradient {
Expand All @@ -328,7 +328,7 @@
#663300 60%, /* orange brown */
#f97316 80%, /* bright orange */
#fbbf24 100%); /* yellow orange */
};
}

/* ASCII Mode for Max Headroom theme */
.ascii-mode {
Expand Down
8 changes: 5 additions & 3 deletions client/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ export default function Home() {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });

// Search is owned by useVideoSearch; it dedupes with ResultsGrid and syncs the store
const { isLoading, error, refetch } = useVideoSearch();
// Home is the single observer that syncs the shared query into the store.
// ResultsGrid observers still receive the deduped query state without
// appending the same result page more than once.
const { isLoading, error, refetch } = useVideoSearch({ syncToStore: true });

// Preload local video on first visit
useEffect(() => {
Expand Down Expand Up @@ -291,4 +293,4 @@ export default function Home() {
</div>
</ResponsiveLayoutManager>
);
}
}
Loading
Loading