feat: add initial command pallete

This commit is contained in:
max_richter 2023-07-31 04:19:04 +02:00
parent d47ffb94bf
commit e3df1fbd19
12 changed files with 488 additions and 11 deletions

View File

@ -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,
};

159
islands/KMenu.tsx Normal file
View File

@ -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<number>;
index: number;
},
) => {
return (
<div
onClick={() => activeIndex.value = index}
class={`px-4 py-2 ${
activeIndex.value === index
? "bg-gray-100 text-gray-900"
: "text-gray-400"
}`}
>
{entry.title}
</div>
);
};
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<HTMLInputElement>(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 (
<div
class={`opacity-${visible.value ? 100 : 0} pointer-events-${
visible.value ? "auto" : "none"
} transition grid place-items-center w-full h-full fixed top-0 left-0 z-50`}
style={{ background: "#1f1f1f88" }}
>
<div
class={`relative w-1/2 max-h-64 rounded-2xl shadow-2xl nnoisy-gradient overflow-hidden after:opacity-10 border border-gray-500`}
style={{ background: "#1f1f1f" }}
>
<div
class="grid h-12 text-gray-400 border-b border-gray-500 "
style={{ gridTemplateColumns: "4em 1fr" }}
>
{activeState.value === "normal" &&
(
<>
<div class="grid place-items-center border-r border-gray-500">
<span class="text-white">
{activeMenu.title}
</span>
</div>
<input
ref={input}
onInput={() => {
commandInput.value = input.current?.value || "";
}}
placeholder="Command"
class="bg-transparent color pl-4 border-0"
/>
</>
)}
{activeState.value === "loading" && (
<div class="p-4">
Loading...
</div>
)}
</div>
{activeState.value === "normal" &&
(
<div>
{filterMenu(activeMenu.entries, input.current?.value).map(
(k, index) => {
return (
<KMenuEntry
entry={k}
activeIndex={activeIndex}
index={index}
/>
);
},
)}
</div>
)}
</div>
</div>
);
};

73
islands/KMenu/commands.ts Normal file
View File

@ -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<string, Menu> = {
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,
},
],
},
};

21
islands/KMenu/types.ts Normal file
View File

@ -0,0 +1,21 @@
import { Signal } from "@preact/signals";
export type MenuState = {
activeMenu: Signal<string>;
activeState: Signal<"error" | "normal" | "loading">;
visible: Signal<boolean>;
menus: Record<string, Menu>;
};
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[];
};

View File

@ -0,0 +1,39 @@
import { useEffect, useRef } from "preact/hooks";
export function useEventListener<T extends Event>(
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
);
}

14
lib/tmdb.ts Normal file
View File

@ -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);
}

16
lib/types.ts Normal file
View File

@ -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;
}

47
routes/api/tmdb/[id].ts Normal file
View File

@ -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<CachedMovieCredits>(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));
};

View File

@ -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<CachedMovieCredits>(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));
};

49
routes/api/tmdb/query.ts Normal file
View File

@ -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<CachedMovieQuery>(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));
};

View File

@ -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<Movie | null> = {
async GET(_, ctx) {
@ -17,6 +18,7 @@ export default function Greet(props: PageProps<Movie>) {
return (
<MainLayout url={props.url}>
<RecipeHero data={movie} backlink="/movies" />
<KMenu type="main" context={movie} />
<div class="px-8 text-white mt-10">
<pre
class="whitespace-break-spaces"

View File

@ -12,7 +12,6 @@ pre {
left: 0;
z-index:0;
position: absolute;
opacity: 0.7;
height: 100%;
width: 100%;
background: url(/grainy-gradient.png);