diff --git a/fresh.gen.ts b/fresh.gen.ts index d27509d..6f5edbf 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -10,13 +10,19 @@ import * as $4 from "./routes/api/movies/[name].ts"; import * as $5 from "./routes/api/movies/index.ts"; import * as $6 from "./routes/api/recipes/[name].ts"; import * as $7 from "./routes/api/recipes/index.ts"; -import * as $8 from "./routes/index.tsx"; -import * as $9 from "./routes/movies/[name].tsx"; -import * as $10 from "./routes/movies/index.tsx"; -import * as $11 from "./routes/recipes/[name].tsx"; -import * as $12 from "./routes/recipes/index.tsx"; +import * as $8 from "./routes/api/tmdb/[id].ts"; +import * as $9 from "./routes/api/tmdb/credits/[id].ts"; +import * as $10 from "./routes/api/tmdb/query.ts"; +import * as $11 from "./routes/index.tsx"; +import * as $12 from "./routes/movies/[name].tsx"; +import * as $13 from "./routes/movies/index.tsx"; +import * as $14 from "./routes/recipes/[name].tsx"; +import * as $15 from "./routes/recipes/index.tsx"; import * as $$0 from "./islands/Counter.tsx"; import * as $$1 from "./islands/IngredientsList.tsx"; +import * as $$2 from "./islands/KMenu.tsx"; +import * as $$3 from "./islands/KMenu/commands.ts"; +import * as $$4 from "./islands/KMenu/types.ts"; const manifest = { routes: { @@ -28,15 +34,21 @@ const manifest = { "./routes/api/movies/index.ts": $5, "./routes/api/recipes/[name].ts": $6, "./routes/api/recipes/index.ts": $7, - "./routes/index.tsx": $8, - "./routes/movies/[name].tsx": $9, - "./routes/movies/index.tsx": $10, - "./routes/recipes/[name].tsx": $11, - "./routes/recipes/index.tsx": $12, + "./routes/api/tmdb/[id].ts": $8, + "./routes/api/tmdb/credits/[id].ts": $9, + "./routes/api/tmdb/query.ts": $10, + "./routes/index.tsx": $11, + "./routes/movies/[name].tsx": $12, + "./routes/movies/index.tsx": $13, + "./routes/recipes/[name].tsx": $14, + "./routes/recipes/index.tsx": $15, }, islands: { "./islands/Counter.tsx": $$0, "./islands/IngredientsList.tsx": $$1, + "./islands/KMenu.tsx": $$2, + "./islands/KMenu/commands.ts": $$3, + "./islands/KMenu/types.ts": $$4, }, baseUrl: import.meta.url, }; diff --git a/islands/KMenu.tsx b/islands/KMenu.tsx new file mode 100644 index 0000000..04f8ac5 --- /dev/null +++ b/islands/KMenu.tsx @@ -0,0 +1,159 @@ +import { Signal, useSignal } from "@preact/signals"; +import { useRef } from "preact/hooks"; +import { useEventListener } from "@lib/hooks/useEventListener.ts"; +import { menus } from "@islands/KMenu/commands.ts"; +import { MenuEntry } from "@islands/KMenu/types.ts"; + +function filterMenu(menu: MenuEntry[], activeMenu?: string) { + return menu; +} + +const KMenuEntry = ( + { entry, activeIndex, index }: { + entry: MenuEntry; + activeIndex: Signal; + index: number; + }, +) => { + return ( +
activeIndex.value = index} + class={`px-4 py-2 ${ + activeIndex.value === index + ? "bg-gray-100 text-gray-900" + : "text-gray-400" + }`} + > + {entry.title} +
+ ); +}; + +export const KMenu = ( + { type = "main", context }: { context: unknown; type: keyof typeof menus }, +) => { + const activeMenuType = useSignal(type); + const activeMenu = menus[activeMenuType.value || "main"]; + + const activeState = useSignal<"normal" | "loading" | "error">("normal"); + + const activeIndex = useSignal(-1); + + const visible = useSignal(false); + const input = useRef(null); + const commandInput = useSignal(""); + + function activateEntry(menuEntry?: MenuEntry) { + if (!menuEntry) return; + + menuEntry.cb({ + activeMenu: activeMenuType, + menus, + activeState, + visible, + }, context); + } + + useEventListener("keydown", (ev: KeyboardEvent) => { + if (ev.key === "/" && ev.ctrlKey) { + visible.value = !visible.value; + } + + if (ev.key === "ArrowDown") { + const entries = filterMenu(activeMenu.entries); + const index = activeIndex.value; + if (index + 1 >= entries.length) { + activeIndex.value = 0; + } else { + activeIndex.value += 1; + } + } + + if (ev.key === "Enter") { + const entries = filterMenu(activeMenu.entries); + activateEntry(entries[activeIndex.value]); + } + + if (ev.key === "ArrowUp") { + const entries = filterMenu(activeMenu.entries); + const index = activeIndex.value; + if (index - 1 < 0) { + activeIndex.value = entries.length - 1; + } else { + activeIndex.value -= 1; + } + } + + if (ev.key === "Escape") { + visible.value = false; + if (input.current) { + input.current.value = ""; + } + } + + if (visible.value) { + input.current?.focus(); + } else { + input.current?.blur(); + } + }); + + return ( +
+
+
+ {activeState.value === "normal" && + ( + <> +
+ + {activeMenu.title} + +
+ { + commandInput.value = input.current?.value || ""; + }} + placeholder="Command" + class="bg-transparent color pl-4 border-0" + /> + + )} + {activeState.value === "loading" && ( +
+ Loading... +
+ )} +
+ {activeState.value === "normal" && + ( +
+ {filterMenu(activeMenu.entries, input.current?.value).map( + (k, index) => { + return ( + + ); + }, + )} +
+ )} +
+
+ ); +}; diff --git a/islands/KMenu/commands.ts b/islands/KMenu/commands.ts new file mode 100644 index 0000000..34016d9 --- /dev/null +++ b/islands/KMenu/commands.ts @@ -0,0 +1,73 @@ +import { Menu } from "@islands/KMenu/types.ts"; +import { Movie } from "@lib/movies.ts"; +import { TMDBMovie } from "@lib/types.ts"; + +export const menus: Record = { + main: { + title: "Run", + entries: [ + { + title: "Close menu", + meta: "", + cb: (state) => { + state.visible.value = false; + }, + visible: () => false, + }, + { + title: "Add Movie infos", + meta: "", + cb: async (state, context) => { + state.activeState.value = "loading"; + const movie = context as Movie; + + let query = movie.name; + + // if (movie.meta.author) { + // query += movie.meta.author; + // } + + const response = await fetch( + `/api/tmdb/query?q=${encodeURIComponent(query)}`, + ); + + console.log(response); + + const json = await response.json() as TMDBMovie[]; + + const menuID = `result/${movie.name}`; + + state.menus[menuID] = { + title: "Select", + entries: json.map((m) => ({ + title: `${m.title} released ${m.release_date}`, + cb: async () => { + state.activeState.value = "loading"; + const res = await fetch(`/api/tmdb/${m.id}`); + const j = await res.json(); + console.log("Selected", { movie, m, j }); + state.visible.value = false; + state.activeState.value = "normal"; + }, + })), + }; + + console.log({ state }); + + state.activeMenu.value = menuID; + + state.activeState.value = "normal"; + }, + visible: () => false, + }, + { + title: "Reload Page", + meta: "", + cb: () => { + window.location.reload(); + }, + visible: () => false, + }, + ], + }, +}; diff --git a/islands/KMenu/types.ts b/islands/KMenu/types.ts new file mode 100644 index 0000000..91b26fc --- /dev/null +++ b/islands/KMenu/types.ts @@ -0,0 +1,21 @@ +import { Signal } from "@preact/signals"; + +export type MenuState = { + activeMenu: Signal; + activeState: Signal<"error" | "normal" | "loading">; + visible: Signal; + menus: Record; +}; + +export type MenuEntry = { + cb: (state: MenuState, context: unknown) => void; + meta?: string; + visible?: boolean | ((context: unknown) => boolean); + title: string; + icon?: string; +}; + +export type Menu = { + title: string; + entries: MenuEntry[]; +}; diff --git a/lib/hooks/useEventListener.ts b/lib/hooks/useEventListener.ts new file mode 100644 index 0000000..d3393ef --- /dev/null +++ b/lib/hooks/useEventListener.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef } from "preact/hooks"; + +export function useEventListener( + eventName: string, + handler: (event: T) => void, + element = window, +) { + // Create a ref that stores handler + const savedHandler = useRef<(event: Event) => void>(); + + // Update ref.current value if handler changes. + // This allows our effect below to always get latest handler ... + // ... without us needing to pass it in effect deps array ... + // ... and potentially cause effect to re-run every render. + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect( + () => { + // Make sure element supports addEventListener + // On + const isSupported = element && element.addEventListener; + if (!isSupported) return; + + // Create event listener that calls handler function stored in ref + const eventListener = (event: T) => savedHandler?.current?.(event); + + // Add event listener + element.addEventListener(eventName, eventListener); + + // Remove event listener on cleanup + return () => { + element.removeEventListener(eventName, eventListener); + }; + }, + [eventName, element], // Re-run if eventName or element changes + ); +} diff --git a/lib/tmdb.ts b/lib/tmdb.ts new file mode 100644 index 0000000..164cbe3 --- /dev/null +++ b/lib/tmdb.ts @@ -0,0 +1,14 @@ +import { MovieDb } from "https://esm.sh/moviedb-promise@3.4.1"; +const moviedb = new MovieDb(Deno.env.get("TMDB_API_KEY") || ""); + +export function searchMovie(query: string) { + return moviedb.searchMovie({ query }); +} + +export function getMovie(id: number) { + return moviedb.movieInfo({ id }); +} + +export function getMovieCredits(id: number) { + return moviedb.movieCredits(id); +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..4e92771 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,16 @@ +export interface TMDBMovie { + adult: boolean; + backdrop_path: string; + genre_ids: number[]; + id: number; + original_language: string; + original_title: string; + overview: string; + popularity: number; + poster_path: string; + release_date: string; + title: string; + video: boolean; + vote_average: number; + vote_count: number; +} diff --git a/routes/api/tmdb/[id].ts b/routes/api/tmdb/[id].ts new file mode 100644 index 0000000..1b9473e --- /dev/null +++ b/routes/api/tmdb/[id].ts @@ -0,0 +1,47 @@ +import { HandlerContext } from "$fresh/server.ts"; +import { getMovie } from "@lib/tmdb.ts"; +import * as cache from "@lib/cache/cache.ts"; + +type CachedMovieCredits = { + lastUpdated: number; + data: unknown; +}; + +const CACHE_INTERVAL = 1000 * 60 * 24 * 30; + +export const handler = async ( + _req: Request, + _ctx: HandlerContext, +) => { + const id = _ctx.params.id; + + if (!id) { + return new Response("Bad Request", { + status: 400, + }); + } + + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + + const cacheId = `/movie/${id}`; + + const cachedResponse = await cache.get(cacheId); + if ( + cachedResponse && Date.now() < (cachedResponse.lastUpdated + CACHE_INTERVAL) + ) { + return new Response(JSON.stringify(cachedResponse.data), { headers }); + } + + const res = await getMovie(+id); + + cache.set( + cacheId, + JSON.stringify({ + lastUpdated: Date.now(), + data: res, + }), + ); + + return new Response(JSON.stringify(res)); +}; diff --git a/routes/api/tmdb/credits/[id].ts b/routes/api/tmdb/credits/[id].ts new file mode 100644 index 0000000..ba21fff --- /dev/null +++ b/routes/api/tmdb/credits/[id].ts @@ -0,0 +1,46 @@ +import { HandlerContext } from "$fresh/server.ts"; +import { getMovieCredits } from "@lib/tmdb.ts"; +import * as cache from "@lib/cache/cache.ts"; + +type CachedMovieCredits = { + lastUpdated: number; + data: unknown; +}; + +const CACHE_INTERVAL = 1000 * 60 * 24 * 30; + +export const handler = async ( + _req: Request, + _ctx: HandlerContext, +) => { + const id = _ctx.params.id; + + if (!id) { + return new Response("Bad Request", { + status: 400, + }); + } + + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + + const cacheId = `/movie/credits/${id}`; + + const cachedResponse = await cache.get(cacheId); + if ( + cachedResponse && Date.now() < (cachedResponse.lastUpdated + CACHE_INTERVAL) + ) { + return new Response(JSON.stringify(cachedResponse.data), { headers }); + } + + const res = await getMovieCredits(+id); + cache.set( + cacheId, + JSON.stringify({ + lastUpdated: Date.now(), + data: res, + }), + ); + + return new Response(JSON.stringify(res)); +}; diff --git a/routes/api/tmdb/query.ts b/routes/api/tmdb/query.ts new file mode 100644 index 0000000..7027405 --- /dev/null +++ b/routes/api/tmdb/query.ts @@ -0,0 +1,49 @@ +import { HandlerContext } from "$fresh/server.ts"; +import { searchMovie } from "@lib/tmdb.ts"; +import * as cache from "@lib/cache/cache.ts"; + +type CachedMovieQuery = { + lastUpdated: number; + data: unknown; +}; + +const CACHE_INTERVAL = 1000 * 60 * 24 * 30; + +export const handler = async ( + _req: Request, + _ctx: HandlerContext, +) => { + const u = new URL(_req.url); + + const query = u.searchParams.get("q"); + + if (!query) { + return new Response("Bad Request", { + status: 400, + }); + } + + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + + const cacheId = `/movie/query/${query}`; + + const cachedResponse = await cache.get(cacheId); + if ( + cachedResponse && Date.now() < (cachedResponse.lastUpdated + CACHE_INTERVAL) + ) { + return new Response(JSON.stringify(cachedResponse.data), { headers }); + } + + const res = await searchMovie(query); + + cache.set( + cacheId, + JSON.stringify({ + lastUpdated: Date.now(), + data: res, + }), + ); + + return new Response(JSON.stringify(res.results)); +}; diff --git a/routes/movies/[name].tsx b/routes/movies/[name].tsx index c8653c3..031f8ad 100644 --- a/routes/movies/[name].tsx +++ b/routes/movies/[name].tsx @@ -3,6 +3,7 @@ import { MainLayout } from "@components/layouts/main.tsx"; import { Movie } from "@lib/movies.ts"; import { getMovie } from "../api/movies/[name].ts"; import { RecipeHero } from "@components/RecipeHero.tsx"; +import { KMenu } from "@islands/KMenu.tsx"; export const handler: Handlers = { async GET(_, ctx) { @@ -17,6 +18,7 @@ export default function Greet(props: PageProps) { return ( +