recuperando informacion y tratandola
This commit is contained in:
@@ -34,3 +34,33 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
|||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
||||||
|
## Development with Docker (recommended for full features)
|
||||||
|
|
||||||
|
The project uses Postgres for caching generated movie insight cards. For the best experience (including persistent cache and automatic migrations):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start everything (app + database)
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Open http://localhost:3000
|
||||||
|
- The first time it will run DB migrations automatically.
|
||||||
|
- Source code is mounted for live reload (`npm run dev` inside the container).
|
||||||
|
- Stop with `docker compose -f docker-compose.dev.yml down`
|
||||||
|
|
||||||
|
Environment variables (TMDB_API_KEY, LLM_API_KEY, etc.) are read from your `.env` file.
|
||||||
|
|
||||||
|
If you prefer running without Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: Without the database the insight cards will still generate (they just won't be cached), thanks to graceful cache handling.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml up --build
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getMovieDetails } from "@/lib/tmdb";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const movieId = Number(id);
|
||||||
|
|
||||||
|
if (!Number.isFinite(movieId) || movieId <= 0) {
|
||||||
|
return NextResponse.json({ error: "Invalid movie ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const movie = await getMovieDetails(movieId);
|
||||||
|
return NextResponse.json(movie);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Movie details fetch failed:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to load movie details" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
-13
@@ -1,29 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { SearchBar } from "@/components/SearchBar";
|
import { SearchBar } from "@/components/SearchBar";
|
||||||
|
import { MovieHeader } from "@/components/MovieHeader";
|
||||||
|
import { MovieInsightPanel } from "@/components/MovieInsightPanel";
|
||||||
|
import type { TmdbMovieDetails, TmdbSearchResult } from "@/lib/tmdb";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const [selectedMovie, setSelectedMovie] = useState<TmdbSearchResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [movieDetails, setMovieDetails] = useState<TmdbMovieDetails | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isLoadingMovie, setIsLoadingMovie] = useState(false);
|
||||||
|
const [movieError, setMovieError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSelect = async (movie: TmdbSearchResult) => {
|
||||||
|
setSelectedMovie(movie);
|
||||||
|
setMovieDetails(null);
|
||||||
|
setMovieError(null);
|
||||||
|
setIsLoadingMovie(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/movie/${movie.id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to load movie details");
|
||||||
|
}
|
||||||
|
const details: TmdbMovieDetails = await res.json();
|
||||||
|
setMovieDetails(details);
|
||||||
|
} catch (err) {
|
||||||
|
setMovieError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load movie details",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMovie(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedMovie(null);
|
||||||
|
setMovieDetails(null);
|
||||||
|
setIsLoadingMovie(false);
|
||||||
|
setMovieError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFullMovie = movieDetails != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col items-center justify-center px-6 py-16">
|
<>
|
||||||
<div className="relative z-10 w-full max-w-2xl space-y-8 text-center">
|
{/* Compact navbar: title + slogan on left, search on right */}
|
||||||
<div className="space-y-3">
|
<nav className="sticky top-0 z-50 border-b border-white/10 bg-slate-950/95 backdrop-blur supports-[backdrop-filter]:bg-slate-950/80">
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-amber-300/80">
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-6">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="font-semibold tracking-tight text-white">
|
||||||
Film Intel
|
Film Intel
|
||||||
</p>
|
</span>
|
||||||
<h1 className="text-4xl font-semibold tracking-tight text-white md:text-5xl">
|
<span className="hidden truncate text-xs text-slate-400 sm:inline">
|
||||||
Movie insight you won't find in one click
|
Movie insight you won't find in one click
|
||||||
</h1>
|
</span>
|
||||||
<p className="text-slate-400">
|
</div>
|
||||||
Ratings explained, skip guides, sensitivity warnings, and deep
|
|
||||||
production context for any film.
|
<div className="w-full max-w-[320px]">
|
||||||
|
<SearchBar
|
||||||
|
autoFocus={!selectedMovie}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="mx-auto min-h-[calc(100vh-3.5rem)] w-full max-w-6xl px-6">
|
||||||
|
{!selectedMovie && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p className="max-w-md text-sm text-slate-400">
|
||||||
|
Search for any film using the bar above. We'll generate
|
||||||
|
ratings explained, skip guides, sensitivity warnings, production
|
||||||
|
facts, and more.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SearchBar autoFocus />
|
{selectedMovie && (
|
||||||
|
<div className="space-y-8 py-8">
|
||||||
|
{hasFullMovie ? (
|
||||||
|
<MovieHeader movie={movieDetails} onClear={clearSelection} />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-semibold text-white">
|
||||||
|
{selectedMovie.title}
|
||||||
|
{selectedMovie.release_date ? (
|
||||||
|
<span className="ml-2 font-normal text-slate-400">
|
||||||
|
({selectedMovie.release_date.slice(0, 4)})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</h2>
|
||||||
|
{selectedMovie.overview && (
|
||||||
|
<p className="text-sm leading-relaxed text-slate-400">
|
||||||
|
{selectedMovie.overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isLoadingMovie && (
|
||||||
|
<p className="text-sm text-amber-300/80">
|
||||||
|
Loading extended details...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{movieError && (
|
||||||
|
<p className="text-sm text-red-400">
|
||||||
|
{movieError}. Card content will still load.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className="mt-auto pt-16 text-center text-xs text-slate-500">
|
<MovieInsightPanel
|
||||||
|
movieId={selectedMovie.id}
|
||||||
|
genres={movieDetails?.genres.map((g) => g.name) ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-white/10 py-6 text-center text-xs text-slate-500">
|
||||||
AI-generated content for informational purposes. Verify timestamps on
|
AI-generated content for informational purposes. Verify timestamps on
|
||||||
your copy.
|
your copy.
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,29 @@ import Link from "next/link";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { getPosterUrl, getUsCertification, type TmdbMovieDetails } from "@/lib/tmdb";
|
import { getPosterUrl, getUsCertification, type TmdbMovieDetails } from "@/lib/tmdb";
|
||||||
|
|
||||||
export function MovieHeader({ movie }: { movie: TmdbMovieDetails }) {
|
export function MovieHeader({
|
||||||
|
movie,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
movie: TmdbMovieDetails;
|
||||||
|
onClear?: () => void;
|
||||||
|
}) {
|
||||||
const poster = getPosterUrl(movie.poster_path, "w500");
|
const poster = getPosterUrl(movie.poster_path, "w500");
|
||||||
const year = movie.release_date?.slice(0, 4);
|
const year = movie.release_date?.slice(0, 4);
|
||||||
const certification = getUsCertification(movie);
|
const certification = getUsCertification(movie);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{onClear ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="inline-flex w-fit items-center gap-2 text-sm text-slate-400 transition hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Search another movie
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="inline-flex w-fit items-center gap-2 text-sm text-slate-400 transition hover:text-amber-200"
|
className="inline-flex w-fit items-center gap-2 text-sm text-slate-400 transition hover:text-amber-200"
|
||||||
@@ -17,6 +33,7 @@ export function MovieHeader({ movie }: { movie: TmdbMovieDetails }) {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to search
|
Back to search
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-end">
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-end">
|
||||||
<div className="relative mx-auto h-56 w-36 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-900 shadow-2xl sm:mx-0">
|
<div className="relative mx-auto h-56 w-36 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-900 shadow-2xl sm:mx-0">
|
||||||
|
|||||||
+53
-23
@@ -3,21 +3,21 @@
|
|||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getPosterUrl } from "@/lib/tmdb";
|
import { getPosterUrl, type TmdbSearchResult } from "@/lib/tmdb";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SearchResult {
|
export function SearchBar({
|
||||||
id: number;
|
autoFocus = false,
|
||||||
title: string;
|
onSelect,
|
||||||
release_date: string;
|
compact = false,
|
||||||
poster_path: string | null;
|
}: {
|
||||||
overview: string;
|
autoFocus?: boolean;
|
||||||
}
|
onSelect?: (movie: TmdbSearchResult) => void;
|
||||||
|
compact?: boolean;
|
||||||
export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<TmdbSearchResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) {
|
|||||||
`/api/search?q=${encodeURIComponent(query)}`,
|
`/api/search?q=${encodeURIComponent(query)}`,
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as { results: SearchResult[] };
|
const data = (await response.json()) as { results: TmdbSearchResult[] };
|
||||||
setResults(data.results ?? []);
|
setResults(data.results ?? []);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -69,10 +69,15 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn("relative w-full max-w-2xl", open && "z-50")}
|
className={cn("relative w-full", open && "z-50", !compact && "max-w-2xl")}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-amber-200/50" />
|
<Search
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-amber-200/50",
|
||||||
|
compact ? "left-3 h-4 w-4" : "left-4 h-5 w-5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
type="search"
|
type="search"
|
||||||
@@ -96,7 +101,12 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) {
|
|||||||
}}
|
}}
|
||||||
onFocus={() => query.trim() && setOpen(true)}
|
onFocus={() => query.trim() && setOpen(true)}
|
||||||
placeholder="Search for a movie..."
|
placeholder="Search for a movie..."
|
||||||
className="w-full rounded-2xl border border-white/10 bg-white/5 py-4 pl-12 pr-4 text-base text-white outline-none transition focus:border-amber-400/40 focus:bg-white/[0.07] focus:ring-2 focus:ring-amber-400/20"
|
className={cn(
|
||||||
|
"w-full border border-white/10 bg-white/5 text-white outline-none transition focus:border-amber-400/40 focus:bg-white/[0.07] focus:ring-2 focus:ring-amber-400/20",
|
||||||
|
compact
|
||||||
|
? "rounded-xl py-2 pl-9 pr-3 text-sm"
|
||||||
|
: "rounded-2xl py-4 pl-12 pr-4 text-base",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,14 +126,8 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) {
|
|||||||
const poster = getPosterUrl(result.poster_path, "w185");
|
const poster = getPosterUrl(result.poster_path, "w185");
|
||||||
const year = result.release_date?.slice(0, 4);
|
const year = result.release_date?.slice(0, 4);
|
||||||
|
|
||||||
return (
|
const inner = (
|
||||||
<li key={result.id} role="option" aria-selected={false}>
|
<>
|
||||||
<Link
|
|
||||||
href={`/movie/${result.id}`}
|
|
||||||
prefetch
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="flex items-center gap-3 border-b border-white/5 px-4 py-3 transition hover:bg-white/5 last:border-b-0"
|
|
||||||
>
|
|
||||||
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded-md bg-slate-800">
|
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded-md bg-slate-800">
|
||||||
{poster ? (
|
{poster ? (
|
||||||
<img
|
<img
|
||||||
@@ -150,7 +154,33 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) {
|
|||||||
{result.overview || "No overview available"}
|
{result.overview || "No overview available"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={result.id} role="option" aria-selected={false}>
|
||||||
|
{onSelect ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(result);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 border-b border-white/5 px-4 py-3 text-left transition hover:bg-white/5 last:border-b-0"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/movie/${result.id}`}
|
||||||
|
prefetch
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex items-center gap-3 border-b border-white/5 px-4 py-3 transition hover:bg-white/5 last:border-b-0"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+30
-1
@@ -1,9 +1,20 @@
|
|||||||
|
# Development Docker Compose (self-contained).
|
||||||
|
# This file defines both the app (with live reload) and the db.
|
||||||
|
#
|
||||||
|
# Usage (from project root):
|
||||||
|
# docker compose -f docker-compose.dev.yml up --build
|
||||||
|
#
|
||||||
|
# After first start the database migrations run automatically before the dev server.
|
||||||
|
# Stop with: docker compose -f docker-compose.dev.yml down
|
||||||
|
#
|
||||||
|
# For production-like: docker compose -f docker-compose.yml up
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: deps
|
target: deps
|
||||||
command: npm run dev
|
command: sh -c "npm run db:migrate && npm run dev"
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
@@ -22,3 +33,21 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: film
|
||||||
|
POSTGRES_PASSWORD: film
|
||||||
|
POSTGRES_DB: film
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U film -d film"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -7,6 +7,7 @@ export async function getCachedCard<T>(
|
|||||||
movieId: number,
|
movieId: number,
|
||||||
cardType: CardType,
|
cardType: CardType,
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -21,6 +22,12 @@ export async function getCachedCard<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rows[0].payload as T;
|
return rows[0].payload as T;
|
||||||
|
} catch (err) {
|
||||||
|
// DB may be unavailable in local dev (e.g. DATABASE_URL points to Docker 'db' host).
|
||||||
|
// Treat as cache miss so card generation can still proceed via LLM.
|
||||||
|
console.warn(`[cache] getCachedCard(${movieId}, ${cardType}) skipped:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setCachedCard<T>(
|
export async function setCachedCard<T>(
|
||||||
@@ -28,6 +35,7 @@ export async function setCachedCard<T>(
|
|||||||
cardType: CardType,
|
cardType: CardType,
|
||||||
payload: T,
|
payload: T,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -44,4 +52,7 @@ export async function setCachedCard<T>(
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[cache] setCachedCard(${movieId}, ${cardType}) skipped:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user