feat: some shit

This commit is contained in:
max_richter 2023-08-08 21:50:23 +02:00
parent 6123956f08
commit c7d0e97ac0
18 changed files with 482 additions and 73 deletions

95
components/Checkbox.tsx Normal file
View File

@ -0,0 +1,95 @@
import { Signal, useSignal } from "@preact/signals";
import { useId, useState } from "preact/hooks";
interface CheckboxProps {
label: string;
isChecked?: boolean;
onChange: (isChecked: boolean) => void;
}
const Checkbox2: preact.FunctionalComponent<CheckboxProps> = (
{ label, isChecked = false, onChange },
) => {
const [checked, setChecked] = useState(isChecked);
const toggleCheckbox = () => {
const newChecked = !checked;
setChecked(newChecked);
onChange(newChecked);
};
return (
<div
class="flex items-center rounded-xl p-1 pl-4"
style={{ background: "var(--background)", color: "var(--foreground)" }}
>
<span>
{label}
</span>
<label
class="relative flex cursor-pointer items-center rounded-full p-3"
for="checkbox"
data-ripple-dark="true"
>
<input
type="checkbox"
class="before:content[''] peer relative h-5 w-5 cursor-pointer appearance-none rounded-md border border-blue-gray-200 transition-all before:absolute before:top-2/4 before:left-2/4 before:block before:h-12 before:w-12 before:-translate-y-2/4 before:-translate-x-2/4 before:rounded-full before:bg-blue-gray-500 before:opacity-0 before:transition-opacity hover:before:opacity-10"
id="checkbox"
checked
/>
<div
class={`pointer-events-none absolute top-2/4 left-2/4 -translate-y-2/4 -translate-x-2/4 text-white opacity-${
checked ? 100 : 0
} transition-opacity`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="white"
stroke="currentColor"
stroke-width="1"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
>
</path>
</svg>
</div>
</label>
</div>
);
};
const Checkbox = (
{ label, checked = useSignal(false) }: {
label: string;
checked?: Signal<boolean>;
},
) => {
const _id = useId();
const id = `checkbox-${_id}`;
return (
<label
className="flex items-center py-3 px-4 rounded-xl"
style={{ color: "var(--foreground)", background: "var(--background)" }}
>
<input
type="checkbox"
checked={checked.value}
name="checkbox-one"
id={id}
onChange={(ev) => {
checked.value = ev.currentTarget.checked;
}}
class="bg-gray-200 hover:bg-gray-300 cursor-pointer
w-5 h-5 border-3 border-amber-500 focus:outline-none rounded-lg"
/>
<span class="ml-3">{label}</span>
</label>
);
};
export default Checkbox;

14
components/Image.tsx Normal file
View File

@ -0,0 +1,14 @@
import { isLocalImage } from "@lib/string.ts";
export function Image(
props: { class: string; src: string; width?: number; height?: number },
) {
if (isLocalImage(props.src)) {
}
return (
<div>
<img src={props.src} width={props.width} height={props.height} />
</div>
);
}

View File

@ -1,5 +1,6 @@
import { Card } from "@components/Card.tsx";
import { Movie } from "@lib/resource/movies.ts";
import { Series } from "@lib/resource/series.ts";
export function MovieCard({ movie }: { movie: Movie }) {
const { meta: { image = "/placeholder.svg" } = {} } = movie;
@ -16,3 +17,19 @@ export function MovieCard({ movie }: { movie: Movie }) {
/>
);
}
export function SeriesCard({ series }: { series: Series }) {
const { meta: { image = "/placeholder.svg" } = {} } = series;
const imageUrl = image?.startsWith("Media/series/")
? `/api/images?image=${image}&width=200&height=200`
: image;
return (
<Card
title={series.name}
image={imageUrl}
link={`/series/${series.id}`}
/>
);
}

34
components/Rating.tsx Normal file
View File

@ -0,0 +1,34 @@
import { IconStar, IconStarFilled } from "@components/icons.tsx";
import { useSignal } from "@preact/signals";
import { useState } from "preact/hooks";
export const Rating = (
props: { max?: number; rating: number },
) => {
const [rating, setRating] = useState(props.rating);
const [hover, setHover] = useState(0);
const max = useSignal(props.max || 5);
return (
<div
class="flex gap-2 px-5 rounded-2xl bg-gray-200 z-10"
style={{ color: "var(--foreground)", background: "var(--background)" }}
>
{Array.from({ length: max.value }).map((_, i) => {
return (
<span
class={`my-5 cursor-pointer opacity-${
(i + 1) <= rating ? 100 : (i + 1) <= hover ? 20 : 100
}`}
onMouseOver={() => setHover(i + 1)}
onClick={() => setRating(i + 1)}
>
{(i + 1) <= rating || (i + 1) <= hover
? <IconStarFilled class="w-4 h-4" />
: <IconStar class="w-4 h-4" />}
</span>
);
})}
</div>
);
};

View File

@ -21,19 +21,21 @@ import * as $15 from "./routes/api/query/index.ts";
import * as $16 from "./routes/api/recipes/[name].ts";
import * as $17 from "./routes/api/recipes/index.ts";
import * as $18 from "./routes/api/resources.ts";
import * as $19 from "./routes/api/series/index.ts";
import * as $20 from "./routes/api/tmdb/[id].ts";
import * as $21 from "./routes/api/tmdb/credits/[id].ts";
import * as $22 from "./routes/api/tmdb/query.ts";
import * as $23 from "./routes/articles/[name].tsx";
import * as $24 from "./routes/articles/index.tsx";
import * as $25 from "./routes/index.tsx";
import * as $26 from "./routes/movies/[name].tsx";
import * as $27 from "./routes/movies/index.tsx";
import * as $28 from "./routes/recipes/[name].tsx";
import * as $29 from "./routes/recipes/index.tsx";
import * as $30 from "./routes/series/[name].tsx";
import * as $31 from "./routes/series/index.tsx";
import * as $19 from "./routes/api/series/[name].ts";
import * as $20 from "./routes/api/series/enhance/[name].ts";
import * as $21 from "./routes/api/series/index.ts";
import * as $22 from "./routes/api/tmdb/[id].ts";
import * as $23 from "./routes/api/tmdb/credits/[id].ts";
import * as $24 from "./routes/api/tmdb/query.ts";
import * as $25 from "./routes/articles/[name].tsx";
import * as $26 from "./routes/articles/index.tsx";
import * as $27 from "./routes/index.tsx";
import * as $28 from "./routes/movies/[name].tsx";
import * as $29 from "./routes/movies/index.tsx";
import * as $30 from "./routes/recipes/[name].tsx";
import * as $31 from "./routes/recipes/index.tsx";
import * as $32 from "./routes/series/[name].tsx";
import * as $33 from "./routes/series/index.tsx";
import * as $$0 from "./islands/Counter.tsx";
import * as $$1 from "./islands/IngredientsList.tsx";
import * as $$2 from "./islands/KMenu.tsx";
@ -66,19 +68,21 @@ const manifest = {
"./routes/api/recipes/[name].ts": $16,
"./routes/api/recipes/index.ts": $17,
"./routes/api/resources.ts": $18,
"./routes/api/series/index.ts": $19,
"./routes/api/tmdb/[id].ts": $20,
"./routes/api/tmdb/credits/[id].ts": $21,
"./routes/api/tmdb/query.ts": $22,
"./routes/articles/[name].tsx": $23,
"./routes/articles/index.tsx": $24,
"./routes/index.tsx": $25,
"./routes/movies/[name].tsx": $26,
"./routes/movies/index.tsx": $27,
"./routes/recipes/[name].tsx": $28,
"./routes/recipes/index.tsx": $29,
"./routes/series/[name].tsx": $30,
"./routes/series/index.tsx": $31,
"./routes/api/series/[name].ts": $19,
"./routes/api/series/enhance/[name].ts": $20,
"./routes/api/series/index.ts": $21,
"./routes/api/tmdb/[id].ts": $22,
"./routes/api/tmdb/credits/[id].ts": $23,
"./routes/api/tmdb/query.ts": $24,
"./routes/articles/[name].tsx": $25,
"./routes/articles/index.tsx": $26,
"./routes/index.tsx": $27,
"./routes/movies/[name].tsx": $28,
"./routes/movies/index.tsx": $29,
"./routes/recipes/[name].tsx": $30,
"./routes/recipes/index.tsx": $31,
"./routes/series/[name].tsx": $32,
"./routes/series/index.tsx": $33,
},
islands: {
"./islands/Counter.tsx": $$0,

View File

@ -3,6 +3,7 @@ import { addMovieInfos } from "@islands/KMenu/commands/add_movie_infos.ts";
import { createNewMovie } from "@islands/KMenu/commands/create_movie.ts";
import { createNewArticle } from "@islands/KMenu/commands/create_article.ts";
import { getCookie } from "@lib/string.ts";
import { addSeriesInfo } from "@islands/KMenu/commands/add_series_infos.ts";
export const menus: Record<string, Menu> = {
main: {
@ -54,6 +55,7 @@ export const menus: Record<string, Menu> = {
return !!getCookie("session_cookie");
},
},
addSeriesInfo,
createNewArticle,
createNewMovie,
addMovieInfos,

View File

@ -46,7 +46,6 @@ export const addMovieInfos: MenuEntry = {
const loc = globalThis["location"];
if (!getCookie("session_cookie")) return false;
return (loc?.pathname?.includes("movie") &&
!loc.pathname.endsWith("movies")) ||
(loc?.pathname?.includes("series") && !loc.pathname.endsWith("series"));
!loc.pathname.endsWith("movies"));
},
};

View File

@ -1,39 +1,43 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { TMDBMovie } from "@lib/types.ts";
import { TMDBSeries } from "@lib/types.ts";
import { getCookie } from "@lib/string.ts";
import { Series } from "@lib/resource/series.ts";
export const addMovieInfos: MenuEntry = {
title: "Add Movie infos",
export const addSeriesInfo: MenuEntry = {
title: "Add Series infos",
meta: "",
icon: "IconReportSearch",
cb: async (state, context) => {
console.log({ state, context });
state.activeState.value = "loading";
const movie = context as Series;
const series = context as Series;
const query = movie.name;
const query = series.name;
const response = await fetch(
`/api/tmdb/query?q=${encodeURIComponent(query)}&type=serie`,
);
const json = await response.json() as TMDBMovie[];
const json = await response.json() as TMDBSeries[];
const menuID = `result/${movie.name}`;
console.log({ json });
const menuID = `result/${series.name}`;
state.menus[menuID] = {
title: "Select",
entries: json.map((m) => ({
title: `${m.title} released ${m.release_date}`,
title: `${m.name || m.original_name} released ${m.first_air_date}`,
cb: async () => {
state.activeState.value = "loading";
await fetch(`/api/movies/enhance/${movie.name}/`, {
console.log({ m });
await fetch(`/api/series/enhance/${series.name}/`, {
method: "POST",
body: JSON.stringify({ tmdbId: m.id }),
});
state.visible.value = false;
state.activeState.value = "normal";
window.location.reload();
//window.location.reload();
},
})),
};
@ -45,8 +49,7 @@ export const addMovieInfos: MenuEntry = {
visible: () => {
const loc = globalThis["location"];
if (!getCookie("session_cookie")) return false;
return (loc?.pathname?.includes("movie") &&
!loc.pathname.endsWith("movies")) ||
(loc?.pathname?.includes("series") && !loc.pathname.endsWith("series"));
return (loc?.pathname?.includes("series") &&
!loc.pathname.endsWith("series"));
},
};

View File

@ -6,6 +6,9 @@ import { SearchResult } from "@lib/types.ts";
import { resources } from "@lib/resources.ts";
import { isLocalImage } from "@lib/string.ts";
import { IS_BROWSER } from "$fresh/runtime.ts";
import Checkbox from "@components/Checkbox.tsx";
import { Rating } from "@components/Rating.tsx";
import { useSignal } from "@preact/signals";
export const RedirectSearchHandler = () => {
useEventListener("keydown", (e: KeyboardEvent) => {
@ -79,15 +82,24 @@ const SearchComponent = (
const [searchQuery, setSearchQuery] = useState(q);
const [data, setData] = useState<SearchResult>();
const [isLoading, setIsLoading] = useState(false);
const showSeenStatus = useSignal(false);
const inputRef = useRef<HTMLInputElement>(null);
if ("history" in globalThis) {
const u = new URL(window.location.href);
if (u.searchParams.get("q") !== searchQuery) {
u.searchParams.set("q", searchQuery);
window.history.replaceState({}, "", u);
}
if (showSeenStatus.value) {
u.searchParams.set("status", "not-seen");
} else {
u.searchParams.delete("status");
}
window.history.replaceState({}, "", u);
}
console.log({ showSeen: showSeenStatus.value });
const fetchData = async (query: string) => {
try {
setIsLoading(true);
@ -98,6 +110,9 @@ const SearchComponent = (
} else {
return;
}
if (showSeenStatus.value) {
fetchUrl.searchParams.set("status", "not-seen");
}
if (type) {
fetchUrl.searchParams.set("type", type);
}
@ -131,25 +146,28 @@ const SearchComponent = (
debouncedFetchData(q);
}, []);
console.log({ data, isLoading });
return (
<div class="mt-2">
<div
class="flex items-center gap-1 rounded-xl w-full shadow-2xl"
style={{ background: "#2B2930", color: "#818181" }}
>
{isLoading && searchQuery
? <IconLoader2 class="w-4 h-4 ml-4 mr-2 animate-spin" />
: <IconSearch class="w-4 h-4 ml-4 mr-2" />}
<input
type="text"
style={{ fontSize: "1.2em" }}
class="bg-transparent py-3 w-full"
ref={inputRef}
value={searchQuery}
onInput={handleInputChange}
/>
</div>
<header class="flex items-center gap-4">
<div
class="flex items-center gap-1 rounded-xl w-full shadow-2xl"
style={{ background: "#2B2930", color: "#818181" }}
>
{isLoading && searchQuery
? <IconLoader2 class="w-4 h-4 ml-4 mr-2 animate-spin" />
: <IconSearch class="w-4 h-4 ml-4 mr-2" />}
<input
type="text"
style={{ fontSize: "1.2em" }}
class="bg-transparent py-3 w-full"
ref={inputRef}
value={searchQuery}
onInput={handleInputChange}
/>
</div>
<Checkbox label="seen" checked={showSeenStatus} />
<Rating rating={4} />
</header>
{data?.hits?.length && !isLoading
? <SearchResultList showEmoji={!type} result={data} />
: isLoading

View File

@ -5,7 +5,6 @@ export function useEventListener<T extends Event>(
handler: (event: T) => void,
element: Window | HTMLElement = window,
) {
console.log("Add Eventlistener", { eventName, element, handler });
// Create a ref that stores handler
const savedHandler = useRef<(event: Event) => void>();

View File

@ -14,10 +14,27 @@ export function getMovie(id: number) {
return moviedb.movieInfo({ id });
}
export function getSeries(id: number) {
return moviedb.tvInfo({ id });
}
export function getMovieCredits(id: number) {
return moviedb.movieCredits(id);
}
export function getSeriesCredits(id: number) {
return moviedb.tvCredits(id);
}
export async function getMovieGenre(id: number) {
const genres = await cache.get("/genres/movies");
return moviedb.genreTvList();
}
export async function getSeriesGenre(id: number) {
const genres = await cache.get("/genres/series");
}
export async function getMoviePoster(id: string): Promise<ArrayBuffer> {
const cachedPoster = await cache.get("posters:" + id);

View File

@ -17,6 +17,22 @@ export interface TMDBMovie {
vote_average: number;
vote_count: number;
}
export interface TMDBSeries {
adult: boolean;
backdrop_path: string;
genre_ids: number[];
id: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path: string;
first_air_date: string;
name: string;
vote_average: number;
vote_count: number;
}
export interface GiteaOauthUser {
sub: string;

View File

@ -76,7 +76,7 @@ async function initializeTypesense() {
{ name: "name", type: "string" },
{ name: "type", type: "string", facet: true },
{ name: "date", type: "string", optional: true },
{ name: "author", type: "string", facet: true },
{ name: "author", type: "string", facet: true, optional: true },
{ name: "rating", type: "int32", facet: true },
{ name: "tags", type: "string[]", facet: true },
{ name: "description", type: "string", optional: true },

View File

@ -9,6 +9,10 @@ export default function App({ Component }: AppProps) {
<link href="/prism-material-dark.css" rel="stylesheet" />
<style>
{`
:root {
--background: rgb(43, 41, 48);
--foreground: rgb(129, 129, 129);
}
/* work-sans-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */

View File

@ -7,23 +7,27 @@ import { extractHashTags } from "@lib/string.ts";
export const handler: Handlers = {
async GET(req, _ctx) {
const url = new URL(req.url);
const query = url.searchParams.get("q");
let query = url.searchParams.get("q");
if (!query) {
throw new BadRequestError('Query parameter "q" is required.');
}
query = decodeURIComponent(query);
const query_by = url.searchParams.get("query_by") ||
"name,description,author,tags";
let filter_by = "";
const filter_by: string[] = [];
const type = url.searchParams.get("type");
if (type) {
filter_by = `type:=${type}`;
filter_by.push(`type:=${type}`);
}
const hashTags = extractHashTags(query);
if (hashTags?.length) {
//filter_by += `tags:=${}`
filter_by.push(`tags:[${hashTags.map((t) => `\`${t}\``).join(",")}]`);
for (const tag of hashTags) {
query = query.replaceAll(`#${tag}`, "");
}
}
const typesenseClient = await getTypeSenseClient();
@ -31,12 +35,16 @@ export const handler: Handlers = {
throw new Error("Query not available");
}
console.log({ query, query_by, filter_by: filter_by.join(" && ") });
// Perform the Typesense search
const searchResults = await typesenseClient.collections("resources")
.documents().search({
q: query,
query_by,
filter_by,
facet_by: "rating,author,tags",
max_facet_values: 10,
filter_by: filter_by.join(" && "),
per_page: 50,
});

View File

@ -0,0 +1,68 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import * as tmdb from "@lib/tmdb.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { safeFileName } from "@lib/string.ts";
import { createDocument } from "@lib/documents.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { createSeries, getSeries, Series } from "@lib/resource/series.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const series = await getSeries(ctx.params.name);
return json(series);
},
async POST(_, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const tmdbId = parseInt(ctx.params.name);
const seriesDetails = await tmdb.getSeries(tmdbId);
const seriesCredits = await tmdb.getSeriesCredits(tmdbId);
const releaseDate = seriesDetails.first_air_date;
const posterPath = seriesDetails.poster_path;
const director =
seriesCredits?.crew?.filter?.((person) => person.job === "Director")[0];
let finalPath = "";
const name = seriesDetails.name || seriesDetails.original_name ||
ctx.params.name;
if (posterPath) {
const poster = await tmdb.getMoviePoster(posterPath);
const extension = fileExtension(posterPath);
finalPath = `Media/series/images/${
safeFileName(name)
}_cover.${extension}`;
await createDocument(finalPath, poster);
}
const metadata = {} as Series["meta"];
if (releaseDate) {
metadata.date = new Date(releaseDate);
}
if (finalPath) {
metadata.image = finalPath;
}
if (director) {
metadata.author = director.name;
}
const series: Series = {
id: name,
name: name,
type: "series",
description: "",
tags: [],
meta: metadata,
};
await createSeries(series);
return json(series);
},
};

View File

@ -0,0 +1,116 @@
import { HandlerContext, Handlers } from "$fresh/server.ts";
import {
createDocument,
getDocument,
transformDocument,
} from "@lib/documents.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import * as tmdb from "@lib/tmdb.ts";
import { parse, stringify } from "https://deno.land/std@0.194.0/yaml/mod.ts";
import { formatDate, safeFileName } from "@lib/string.ts";
import { json } from "@lib/helpers.ts";
import {
AccessDeniedError,
BadRequestError,
NotFoundError,
} from "@lib/errors.ts";
import { getSeries, Series } from "@lib/resource/series.ts";
async function updateSeriesMetadata(
name: string,
metadata: Partial<Series["meta"]>,
) {
const docId = `Media/series/${name}.md`;
console.log({ docId, metadata });
let currentDoc = await getDocument(docId);
if (!currentDoc) {
throw new NotFoundError();
}
if (!currentDoc.startsWith("---\n---\n")) {
currentDoc = `---\n---\n\n${currentDoc}`;
}
const newDoc = transformDocument(currentDoc, (root) => {
const frontmatterNode = root.children.find((c) => c.type === "yaml");
const frontmatter = frontmatterNode?.value as string;
const value = parse(frontmatter) as Series["meta"];
const newValue = {
...metadata,
date: formatDate(metadata.date),
...value,
};
frontmatterNode.value = stringify(newValue);
return root;
});
console.log(newDoc);
return createDocument(docId, newDoc);
}
const POST = async (
req: Request,
ctx: HandlerContext,
): Promise<Response> => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const body = await req.json();
const name = ctx.params.name;
const { tmdbId } = body;
if (!name || !tmdbId) {
throw new BadRequestError();
}
const series = await getSeries(ctx.params.name);
if (!series) {
throw new NotFoundError();
}
const seriesDetails = await tmdb.getSeries(tmdbId);
const seriesCredits = !series.meta.author &&
await tmdb.getSeriesCredits(tmdbId);
const releaseDate = seriesDetails.first_air_date;
const posterPath = seriesDetails.poster_path;
const director = seriesCredits &&
seriesCredits.crew?.filter?.((person) => person.job === "Director")[0];
let finalPath = "";
if (posterPath && !series.meta.image) {
const poster = await tmdb.getMoviePoster(posterPath);
const extension = fileExtension(posterPath);
finalPath = `Media/series/images/${safeFileName(name)}_cover.${extension}`;
await createDocument(finalPath, poster);
}
const metadata = {} as Series["meta"];
if (releaseDate) {
metadata.date = new Date(releaseDate);
}
if (finalPath) {
metadata.image = finalPath;
}
if (director && director.name) {
metadata.author = director.name;
}
await updateSeriesMetadata(name, metadata);
return json(seriesCredits);
};
export const handler: Handlers = {
POST,
};

View File

@ -6,6 +6,7 @@ import { getAllSeries, Series } from "@lib/resource/series.ts";
import { Card } from "@components/Card.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import { SeriesCard } from "@components/MovieCard.tsx";
export const handler: Handlers<Series[] | null> = {
async GET(_, ctx) {
@ -32,13 +33,7 @@ export default function Greet(props: PageProps<Series[] | null>) {
</header>
<Grid>
{props.data?.map((doc) => {
return (
<Card
image={doc?.meta?.image || "/placeholder.svg"}
link={`/series/${doc.id}`}
title={doc.name}
/>
);
return <SeriesCard series={doc} />;
})}
</Grid>
</MainLayout>