Compare commits

..

59 Commits

Author SHA1 Message Date
Max Richter
47d32d68c7 fix: lazily import sharp to fix commonjs error 2026-01-10 20:13:15 +01:00
Max Richter
c74a19b527 fix(ci): make dockerfile work 2026-01-10 19:49:07 +01:00
Max Richter
8d712322c0 fix: make it work with new vite 2026-01-10 19:28:09 +01:00
Max Richter
694feb083d fix: remove all linter errors 2026-01-10 15:06:43 +01:00
Max Richter
e55f787a29 feat: trying to add hashes to scripts 2026-01-10 13:03:29 +01:00
e65938ecc2 fix: round instead of floor in IngredientList 2026-01-08 16:43:55 +01:00
Max Richter
7dda2dd60d feat: add kmenu icon for mobile 2025-11-12 16:12:57 +01:00
Max Richter
7ad08daf80 fix: make recipe crawling work 2025-11-12 15:41:30 +01:00
Max Richter
92126882b6 feat: correctly size search result items 2025-11-12 13:35:39 +01:00
Max Richter
655fc648e6 feat: fallback to unsplash cover when article contains no image 2025-11-09 23:52:53 +01:00
Max Richter
6c6b69a46a fix: make search work 2025-11-07 18:58:23 +01:00
Max Richter
97c5b7f93c fix: better position covers 2025-11-07 18:26:31 +01:00
Max Richter
bf3483019c fix: make unseen search work 2025-11-07 18:18:39 +01:00
Max Richter
5502c17c28 fix: correctly display noise gradient 2025-11-07 17:54:56 +01:00
Max Richter
581f1c1926 fix: make search usable again 2025-11-05 00:42:53 +01:00
Max Richter
7664abe089 fix: actually write image 2025-11-04 16:04:51 +01:00
Max Richter
bed7d1a11b feat: better log article creations 2025-11-04 13:57:32 +01:00
Max Richter
56a104c8b9 feat: use better names for md files 2025-11-04 13:26:49 +01:00
Max Richter
3103ed19fb fix: correctly fetch marka pi in background 2025-11-04 12:58:26 +01:00
Max Richter
fea9b69d4d feat: cache marka api responses 2025-11-04 12:09:17 +01:00
Max Richter
bb4e895770 fix: display correct name for series 2025-11-03 01:06:27 +01:00
Max Richter
28e9de4dc8 fix: make sure to only decode thumbhash once 2025-11-03 00:57:44 +01:00
Max Richter
ebb897dca4 fix: correctly embed styles.css 2025-11-03 00:46:49 +01:00
Max Richter
696082250d fix: soo many lint errors 2025-11-03 00:03:27 +01:00
Max Richter
c13420c3ab chore: deno fmt 2025-11-02 21:58:02 +01:00
Max Richter
21124dfe00 fix: remove unused imports 2025-11-02 21:56:26 +01:00
Max Richter
928782c453 fix: accessing nonexistant variable 2025-11-02 21:14:41 +01:00
Max Richter
098da12ac4 fix: make recipe ingredinets interactive 2025-11-02 20:01:01 +01:00
Max Richter
d4a7763b15 fix: accessing nonexistant variable 2025-11-02 19:19:57 +01:00
Max Richter
21841b4dc4 fix: use correct url 2025-11-02 19:03:34 +01:00
Max Richter
e6b90cb785 feat: refactor whole bunch of stuff 2025-11-02 19:03:11 +01:00
Max Richter
81ebc8f5e0 fix: display correct name on movie page 2025-10-31 20:15:00 +01:00
Max Richter
65d76dcb26 fix: update some more code 2025-10-31 18:27:47 +01:00
Max Richter
fcbb9e7f22 fix: make image work 2025-10-31 18:25:08 +01:00
Max Richter
d7037e1ca1 fix: post images with as correct arraybuffer to marka 2025-10-31 18:12:24 +01:00
Max Richter
ab9c0f96e0 fix: make sure series/movies endpoints use correct type 2025-10-31 17:55:02 +01:00
Max Richter
de0b2e7a8f fix: refactor some stuff 2025-10-31 17:34:06 +01:00
Max Richter
5001fe62c1 fix: error in deno.json 2025-10-31 17:03:25 +01:00
Max Richter
91812ad38f fix: remove unused packages 2025-10-31 16:23:35 +01:00
Max Richter
1f67f8af34 feat: use marka api in all apis 2025-10-31 16:23:20 +01:00
Max Richter
79d692c2c6 fix: dont import node:path in clientside components 2025-10-31 15:41:46 +01:00
Max Richter
7e60327940 feat: allow creating articles with marka 2025-10-31 15:26:34 +01:00
Max Richter
dfa3826ec5 chore: use same fs version everywhere 2025-10-28 20:36:03 +01:00
Max Richter
d9403925c5 chore: fresh upgrade 2025-10-28 20:34:16 +01:00
Max Richter
283a00be39 fix: correctly fetch articles/series 2025-10-28 20:30:25 +01:00
Max Richter
f680b5f832 feat: initial refactor to use marka as backend 2025-10-28 20:15:23 +01:00
0beb3b1071 refactor: split telegram.ts into seperate files 2025-05-22 15:36:38 +02:00
001c524d73 fix: reply in html 2025-05-09 19:57:18 +02:00
9dc01a59be feat: some more logs 2025-05-09 19:51:25 +02:00
a414a80766 fix: dont inline the image in telegram response 2025-05-09 19:46:41 +02:00
46519ef1ea feat: print images in chat 2025-05-09 19:39:54 +02:00
6f9717f530 feat: add some error messages 2025-05-09 19:32:00 +02:00
6883780d57 fix: install ffmpeg in docker 2025-05-09 19:14:45 +02:00
acefbcbd14 feat: add telegram bot 2025-05-09 19:05:23 +02:00
d450f4ed42 feat: dont show image duplicate in beginnign of markdown 2025-01-26 02:00:59 +01:00
6a54bdeec6 feat: move anchor item to left of text in markdown 2025-01-25 19:00:06 +01:00
4ff7ef7b5c fix: dont double cache documents 2025-01-25 18:57:06 +01:00
23f33b7472 fix: dont double cache documents 2025-01-25 18:51:10 +01:00
0f146ea699 fix: handle invalid dates 2025-01-25 18:36:41 +01:00
179 changed files with 6780 additions and 4671 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,3 @@
# dotenv environment variable files
.env
.env.development.local
.env.test.local
@@ -10,3 +9,4 @@ data-dev/
_fresh/
node_modules/
mise.toml

View File

@@ -1,7 +1,7 @@
FROM denoland/deno:2.1.4 AS build
FROM denoland/deno:2.6.4 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
curl && \
curl ffmpeg && \
deno run -A npm:playwright install --with-deps firefox &&\
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -15,11 +15,11 @@ COPY . .
ENV DATA_DIR=/app/data
RUN mkdir -p $DATA_DIR && \
deno install --allow-import --allow-ffi --allow-scripts=npm:sharp@0.33.5-rc.1 -e main.ts &&\
deno install --allow-import --allow-ffi -e main.ts &&\
sed -i -e 's/"deno"/"no-deno"/' node_modules/@libsql/client/package.json &&\
deno install npm:@libsql/linux-x64-gnu &&\
deno task build
EXPOSE 8000
CMD ["run", "-A", "main.ts"]
CMD ["task", "start"]

View File

@@ -5,18 +5,13 @@ Started" guide here: https://fresh.deno.dev/docs/getting-started
### Usage
Make sure to install Deno: https://deno.land/manual/getting_started/installation
Make sure to install Deno:
https://docs.deno.com/runtime/getting_started/installation
Then start the project:
Then start the project in development mode:
```
deno task start
deno task dev
```
This will watch the project directory and restart as necessary.
## FIX
```
sed -i -e 's/"deno"/"no-deno"/' node_modules/@libsql/client/package.json
```

File diff suppressed because one or more lines are too long

1
assets/styles.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

2
client.ts Normal file
View File

@@ -0,0 +1,2 @@
// Import CSS files here for hot module reloading to work.
import "./assets/styles.css";

View File

@@ -1,12 +1,10 @@
import { JSX } from "preact";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { ButtonHTMLAttributes } from "preact";
export function Button(props: JSX.HTMLAttributes<HTMLButtonElement>) {
export function Button(props: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
disabled={!IS_BROWSER || props.disabled}
class={`px-2 py-1 ${props.class ? props.class : " "}`}
class={`cursor-pointer px-2 py-1 ${props.class ? props.class : ""}`}
/>
);
}

View File

@@ -1,8 +1,8 @@
import { isYoutubeLink } from "@lib/string.ts";
import { IconBrandYoutube } from "@components/icons.tsx";
import { GenericResource } from "@lib/types.ts";
import { SmallRating } from "@components/Rating.tsx";
import { Link } from "@islands/Link.tsx";
import { parseRating } from "@lib/helpers.ts";
import { GenericResource, getNameOfResource } from "@lib/marka/schema.ts";
export function Card(
{
@@ -10,20 +10,20 @@ export function Card(
rating,
title,
image,
thumbnail,
thumbhash,
backgroundColor,
backgroundSize = 100,
}: {
backgroundSize?: number;
backgroundColor?: string;
thumbnail?: string;
thumbhash?: string;
link?: string;
title?: string;
image?: string;
rating?: number;
},
) {
const backgroundStyle: preact.JSX.CSSProperties = {
const backgroundStyle: preact.CSSProperties = {
backgroundSize: "cover",
backgroundColor: backgroundColor,
};
@@ -35,16 +35,15 @@ export function Card(
}
return (
<Link
<a
href={link}
style={backgroundStyle}
data-thumb={thumbnail}
data-thumb={thumbhash}
class="text-white rounded-3xl shadow-md relative
lg:w-56 lg:h-56
sm:w-48 sm:h-48
w-[37vw] h-[37vw]"
>
{true && (
<span class="absolute top-0 left-0 overflow-hidden rounded-3xl w-full h-full">
<img
class="w-full h-full object-cover"
@@ -53,7 +52,6 @@ export function Card(
src={image || "/placeholder.svg"}
/>
</span>
)}
<div
class="p-4 flex flex-col justify-between relative z-10"
style={{
@@ -89,27 +87,31 @@ export function Card(
)}
</div>
<div class="absolute inset-x-0 bottom-0 h-3/4" />
</Link>
</a>
);
}
export function ResourceCard(
{ res, sublink = "movies" }: { sublink?: string; res: GenericResource },
) {
const { meta: { image } = {} } = res || {};
const img = res?.image?.url;
const imageUrl = image
? `/api/images?image=${image}&width=200&height=200`
const imageUrl = img
? `/api/images?image=${img}&width=200&height=200`
: "/placeholder.svg";
const rating = res.content.reviewRating?.ratingValue
? parseRating(res.content.reviewRating.ratingValue)
: undefined;
return (
<Card
title={res.name}
backgroundColor={res.meta?.average}
rating={res.meta?.rating}
thumbnail={res.meta?.thumbnail}
title={getNameOfResource(res)}
backgroundColor={res.image?.average}
thumbhash={res.image?.thumbhash}
rating={rating}
image={imageUrl}
link={`/${sublink}/${res.id}`}
link={`/${sublink}/${res.name.replace(/\.md$/g, "")}`}
/>
);
}

View File

@@ -1,67 +1,5 @@
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>
);
};
import { useId } from "preact/hooks";
const Checkbox = (
{ label, checked = useSignal(false) }: {

View File

@@ -1,4 +1,4 @@
import { asset } from "$fresh/runtime.ts";
import { asset } from "fresh/runtime";
export const Emoji = (props: { class?: string; name: string }) => {
return props.name
@@ -10,5 +10,5 @@ export const Emoji = (props: { class?: string; name: string }) => {
/>
)
: <span>{props.name}</span>
: <></>;
: null;
};

View File

@@ -1,5 +1,4 @@
import { asset } from "$fresh/runtime.ts";
import * as CSS from "https://esm.sh/csstype@3.1.2";
import { asset } from "fresh/runtime";
interface ResponsiveAttributes {
srcset: string;
@@ -34,11 +33,11 @@ const Image = (
class: string;
src: string;
alt?: string;
thumbnail?: string;
thumbhash?: string;
fill?: boolean;
width?: number | string;
height?: number | string;
style?: CSS.HtmlAttributes;
style?: preact.CSSProperties;
},
) => {
const responsiveAttributes = generateResponsiveAttributes(
@@ -47,6 +46,11 @@ const Image = (
"/api/images",
);
const hasDimensions = typeof props.width === "number" &&
typeof props.height === "number";
const sizes = hasDimensions ? "" : responsiveAttributes.sizes;
const srcset = hasDimensions ? "" : responsiveAttributes.srcset;
return (
<span
style={{
@@ -55,16 +59,15 @@ const Image = (
height: props.fill ? "100%" : "",
zIndex: props.fill ? -1 : "",
}}
data-thumb={props.thumbnail}
data-thumb={props.thumbhash}
>
<img
data-thumb={props.thumbnail}
data-thumb-img
loading="lazy"
alt={props.alt}
style={props.style}
srcset={responsiveAttributes.srcset}
sizes={responsiveAttributes.sizes}
sizes={sizes}
srcset={srcset}
src={`/api/images?image=${asset(props.src)}${
props.width ? `&width=${props.width}` : ""
}${props.height ? `&height=${props.height}` : ""}`}

View File

@@ -1,37 +1,42 @@
import { GenericResource } from "@lib/types.ts";
import { Head } from "$fresh/runtime.ts";
import { Head } from "fresh/runtime";
import { GenericResource, getNameOfResource } from "@lib/marka/schema.ts";
import { formatDate } from "@lib/string.ts";
function generateJsonLd(resource: GenericResource): string {
const imageUrl = resource.meta?.image
? `/api/images?image=${resource.meta.image}&width=1200`
const imageUrl = resource.content?.image
? `/api/images?image=${resource.content.image}&width=1200`
: "/images/og-image.jpg";
const baseSchema: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": resource.type.charAt(0).toUpperCase() + resource.type.slice(1), // Converts type to PascalCase
"@type": resource.content?._type,
name: resource.name,
description: resource.content || resource.meta?.average || "",
keywords: resource.tags?.join(", ") || "",
description: resource.content || "",
keywords: resource.content.keywords?.join(", ") || "",
image: imageUrl,
};
if (resource.meta?.author) {
if (resource.content?.author) {
baseSchema.author = {
"@type": "Person",
name: resource.meta.author,
name: resource.content.author,
};
}
if (resource.meta?.date) {
baseSchema.datePublished = new Date(resource.meta.date).toISOString();
if (resource.content?.datePublished) {
try {
baseSchema.datePublished = formatDate(
resource.content.datePublished,
);
} catch (_) {
// Ignore invalid date
}
}
if (resource.meta?.rating) {
baseSchema.aggregateRating = {
"@type": "AggregateRating",
ratingValue: resource.meta.rating,
ratingCount: 1,
bestRating: 5, // Assuming a scale of 1 to 10
if (resource.content?.reviewRating) {
baseSchema.reviewRating = {
"@type": "Rating",
...resource.content.reviewRating,
};
}
@@ -41,20 +46,21 @@ function generateJsonLd(resource: GenericResource): string {
export function MetaTags({ resource }: { resource: GenericResource }) {
const jsonLd = generateJsonLd(resource);
const imageUrl = resource.meta?.image
? `/api/images?image=${resource.meta.image}&width=1200`
const imageUrl = resource.content?.image
? `/api/images?image=${resource.content.image}&width=1200`
: "/images/og-image.jpg";
return (
<>
<Head>
<meta property="og:title" content={resource.name} />
<meta property="og:type" content={resource.type} />
<meta property="og:title" content={getNameOfResource(resource)} />
<meta property="og:type" content={resource.content?._type} />
<meta
property="og:image"
content={imageUrl}
/>
<script
type="application/ld+json"
// deno-lint-ignore react-no-danger
dangerouslySetInnerHTML={{ __html: jsonLd }}
/>
</Head>

View File

@@ -6,22 +6,22 @@ import { IconArrowNarrowLeft } from "@components/icons.tsx";
import { IconEdit } from "@components/icons.tsx";
import { useContext } from "preact/hooks";
const HeroContext = createContext<{ image?: string; thumbnail?: string }>({
const HeroContext = createContext<{ image?: string; thumbhash?: string }>({
image: undefined,
thumbnail: undefined,
thumbhash: undefined,
});
function Wrapper(
{ children, image, thumbnail }: {
{ children, image, thumbhash }: {
children: ComponentChildren;
image?: string;
thumbnail?: string;
thumbhash?: string;
},
) {
return (
<div
class={`flex justify-between flex-col relative w-full ${
image ? "min-h-[400px]" : "min-h-[200px]"
image ? "min-h-100" : "min-h-50"
} rounded-3xl overflow-hidden`}
>
<HeroContext.Provider value={{ image }}>
@@ -30,7 +30,7 @@ function Wrapper(
<Image
fill
src={image}
thumbnail={thumbnail}
thumbhash={thumbhash}
alt="Recipe Banner"
// style={{ objectPosition: "0% 25%" }}
class="absolute object-cover w-full h-full -z-10"
@@ -62,7 +62,7 @@ function Title(
>
{children}
{link &&
<IconExternalLink />}
<IconExternalLink class="h-6 w-6" />}
</h2>
</OuterTag>
);
@@ -104,23 +104,24 @@ function Header({ children }: { children: ComponentChildren }) {
function Subline(
{ entries, children }: {
children?: ComponentChildren;
entries: (string | { href: string; title: string })[];
entries: (string | undefined | { href: string; title: string })[];
},
) {
const ctx = useContext(HeroContext);
return (
<div
class={`relative flex items-center z-10 flex gap-5 font-sm text-light mt-3`}
class={`relative items-center z-10 flex gap-5 font-sm text-light mt-3`}
style={{ color: ctx.image ? "#1F1F1F" : "white" }}
>
{children}
{entries.filter((s) =>
s && (typeof s === "string" ? s?.length > 1 : true)
).map((s) => {
if (!s) return;
if (typeof s === "string") {
return <span>{s}</span>;
return <span key={s}>{s}</span>;
} else {
return <a href={s.href}>{s.title}</a>;
return <a key={s.href} href={s.href}>{s.title}</a>;
}
})}
</div>

View File

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

View File

@@ -10,9 +10,9 @@ export const Star = (
>
{Array.from({ length: max }).map((_, i) => {
if ((i + 1) <= rating) {
return <IconStarFilled class="w-4 h-4" />;
return <IconStarFilled key={i} class="w-4 h-4" />;
}
return <IconStar class="w-4 h-4" />;
return <IconStar key={i} class="w-4 h-4" />;
})}
</div>
);

View File

@@ -1,19 +1,23 @@
export { default as IconStar } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/star.tsx";
export { default as IconStarFilled } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/star-filled.tsx";
export { default as IconExternalLink } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/external-link.tsx";
export { default as IconArrowNarrowLeft } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/arrow-narrow-left.tsx";
export { default as IconEdit } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/edit.tsx";
export { default as IconArrowLeft } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/arrow-left.tsx";
export { default as IconError404 } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/error-404.tsx";
export { default as IconSquareRoundedPlus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/square-rounded-plus.tsx";
export { default as IconReportSearch } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/report-search.tsx";
export { default as IconRefresh } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/refresh.tsx";
export { default as IconCirclePlus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/circle-plus.tsx";
export { default as IconCircleMinus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/circle-minus.tsx";
export { default as IconLoader2 } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/loader-2.tsx";
export { default as IconLogin } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/login.tsx";
export { default as IconLogout } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/logout.tsx";
export { default as IconSearch } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/search.tsx";
export { default as IconGhost } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/ghost.tsx";
export { default as IconBrandYoutube } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/brand-youtube.tsx";
export { default as IconWand } from "https://deno.land/x/tabler_icons_tsx@0.0.5/tsx/wand.tsx";
export {
TbAlertCircle as IconAlertCircle,
TbArrowLeft as IconArrowLeft,
TbArrowNarrowLeft as IconArrowNarrowLeft,
TbBrandYoutube as IconBrandYoutube,
TbCircleMinus as IconCircleMinus,
TbCirclePlus as IconCirclePlus,
TbEdit as IconEdit,
TbError404 as IconError404,
TbExternalLink as IconExternalLink,
TbGhost as IconGhost,
TbLoader2 as IconLoader2,
TbLogin as IconLogin,
TbLogout as IconLogout,
TbMenu2 as IconMenu2,
TbRefresh as IconRefresh,
TbReportSearch as IconReportSearch,
TbSearch as IconSearch,
TbSquareRoundedPlus as IconSquareRoundedPlus,
TbStar as IconStar,
TbStarFilled as IconStarFilled,
TbWand as IconWand,
} from "@preact-icons/tb";

View File

@@ -1,6 +1,6 @@
import { ComponentChildren } from "preact";
import Search from "@islands/Search.tsx";
import { GenericResource } from "@lib/types.ts";
import { GenericResource } from "@lib/marka/schema.ts";
export type Props = {
children: ComponentChildren;
@@ -12,17 +12,25 @@ export type Props = {
searchResults?: GenericResource[];
};
function getQFromUrl(u: string | URL): string | null {
try {
const _u = typeof u === "string" ? new URL(u) : u;
return _u?.searchParams.get("q");
} catch (_e) {
return null;
}
}
export const MainLayout = (
{ children, url, context, searchResults }: Props,
) => {
const _url = typeof url === "string" ? new URL(url) : url;
const hasSearch = _url?.search?.includes("q=");
const q = getQFromUrl(url);
if (hasSearch) {
if (typeof q === "string") {
return (
<Search
q={_url.searchParams.get("q")}
{...context}
q={q}
results={searchResults}
/>
);

View File

@@ -4,10 +4,10 @@ services:
context: .
dockerfile: Dockerfile
volumes:
- .:/app # Mount the local directory to /app in the container
working_dir: /app # Set the working directory inside the container to /app
command: run --env-file -A --watch=static/,routes/ dev.ts # Custom start command
- .:/app
working_dir: /app
command: deno task dev --host 0.0.0.0
ports:
- "8000:8000" # Expose the container port
- "8000:8000"
environment:
- DATA_DIR=/app/data # Set the environment variable inside the container
- DATA_DIR=/app/data

110
deno.json
View File

@@ -1,16 +1,12 @@
{
"lock": false,
"nodeModulesDir": "auto",
"unstable": [
"cron"
],
"nodeModulesDir": "manual",
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"start": "deno run --env-file -A --watch=static/,routes/ dev.ts",
"check": "deno fmt --check . && deno lint . && deno check",
"dev": "vite",
"db": "deno run --env-file -A npm:drizzle-kit",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
"build": "vite build",
"start": "deno serve -A _fresh/server.js",
"update": "deno run -A -r jsr:@fresh/update ."
},
"lint": {
"rules": {
@@ -20,46 +16,84 @@
]
}
},
"exclude": [
"**/_fresh/*"
],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@cmd-johnson/oauth2-client": "jsr:@cmd-johnson/oauth2-client@^2.0.0",
"@components": "./components",
"@components/": "./components/",
"@denosaurs/emoji": "jsr:@denosaurs/emoji@^0.3.1",
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
"@islands": "./islands",
"@islands/": "./islands/",
"@lib": "./lib",
"@lib/": "./lib/",
"@libsql/client": "npm:@libsql/client@^0.14.0",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"@std/http": "jsr:@std/http@^1.0.12",
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"drizzle-kit": "npm:drizzle-kit@^0.30.1",
"drizzle-orm": "npm:drizzle-orm@^0.38.3",
"@/": "./",
"@libsql/client": "npm:@libsql/client@^0.17.0",
"@libsql/linux-x64-gnu": "npm:@libsql/linux-x64-gnu@^0.5.22",
"@openai/openai": "jsr:@openai/openai@^6.16.0",
"@preact-icons/tb": "jsr:@preact-icons/tb@^1.0.14",
"@std/http": "jsr:@std/http@^1.0.23",
"@std/media-types": "jsr:@std/media-types@^1.1.0",
"@zaubrik/djwt": "jsr:@zaubrik/djwt@^3.0.2",
"defuddle": "npm:defuddle@^0.6.6",
"drizzle-kit": "npm:drizzle-kit@^0.31.8",
"drizzle-orm": "npm:drizzle-orm@^0.45.1",
"fresh": "jsr:@fresh/core@^2.2.0",
"fuzzysort": "npm:fuzzysort@^3.1.0",
"playwright": "npm:playwright@^1.49.1",
"gfm": "jsr:@deno/gfm@0.11.0",
"jsdom": "npm:jsdom@^27.4.0",
"moviedb-promise": "npm:moviedb-promise@^4.0.7",
"parse-ingredient": "npm:parse-ingredient@^1.3.1",
"playwright-extra": "npm:playwright-extra@^4.3.6",
"preact": "https://esm.sh/preact@10.22.0",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2",
"preact/": "https://esm.sh/preact@10.22.0/",
"gfm": "jsr:@deno/gfm",
"preact": "npm:preact@^10.27.2",
"@preact/signals": "npm:@preact/signals@^2.5.0",
"@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8",
"puppeteer-extra-plugin-stealth": "npm:puppeteer-extra-plugin-stealth@^2.11.2",
"tailwindcss": "npm:tailwindcss@^3.4.17",
"tailwindcss/": "npm:/tailwindcss@^3.4.17/",
"tailwindcss/plugin": "npm:/tailwindcss@^3.4.17/plugin.js",
"camelcase-css": "npm:camelcase-css",
"tsx": "npm:tsx@^4.19.2",
"yaml": "https://deno.land/std@0.197.0/yaml/mod.ts",
"zod": "npm:zod@^3.24.1",
"domparser": "https://deno.land/x/deno_dom@v0.1.48/deno-dom-wasm.ts",
"fs": "https://deno.land/std/fs/mod.ts",
"imagemagick": "https://deno.land/x/imagemagick_deno@0.0.31/mod.ts"
"sharp": "npm:sharp@^0.34.5",
"thumbhash": "npm:thumbhash@^0.1.1",
"turndown": "npm:turndown@^7.2.2",
"vite": "npm:vite@^7.1.3",
"tailwindcss": "npm:tailwindcss@^4.1.10",
"@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.12",
"zod": "npm:zod@^4.3.5"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
"lib": [
"dom",
"dom.asynciterable",
"dom.iterable",
"deno.ns"
],
"jsx": "precompile",
"jsxImportSource": "preact",
"jsxPrecompileSkipElements": [
"a",
"img",
"source",
"body",
"html",
"head",
"title",
"meta",
"script",
"link",
"style",
"base",
"noscript",
"template"
],
"types": [
"vite/client"
]
},
"exclude": [
"**/_fresh/*"
"allowScripts": {
"allow": [
"npm:sharp@0.34.5",
"npm:esbuild@0.27.2",
"npm:esbuild@0.18.20",
"npm:esbuild@0.25.12",
"npm:esbuild@0.25.7"
]
}
}

2912
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

8
dev.ts
View File

@@ -1,8 +0,0 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";
await dev(import.meta.url, "./main.ts", config);
Deno.exit(0);

View File

@@ -2,19 +2,21 @@ CREATE TABLE `performance` (
`path` text NOT NULL,
`search` text,
`time` integer NOT NULL,
`created_at` integer DEFAULT (current_timestamp)
`created_at` integer DEFAULT (CURRENT_TIMESTAMP)
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer DEFAULT (current_timestamp),
`created_at` integer DEFAULT (CURRENT_TIMESTAMP),
`expires_at` integer NOT NULL,
`user_id` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer DEFAULT (current_timestamp) NOT NULL,
`created_at` integer DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`email` text NOT NULL,
`name` text NOT NULL
);

View File

@@ -1 +1,4 @@
ALTER TABLE `performance` ALTER COLUMN "created_at" TO "created_at" integer DEFAULT (STRFTIME('%s', 'now') * 1000);
ALTER TABLE
`performance`
ALTER COLUMN
"created_at" TO "created_at" integer DEFAULT (STRFTIME('%s', 'now') * 1000);

View File

@@ -1,5 +1,5 @@
CREATE TABLE `image` (
`created_at` integer DEFAULT (current_timestamp),
`created_at` integer DEFAULT (CURRENT_TIMESTAMP),
`url` text NOT NULL,
`average` text NOT NULL,
`blurhash` text NOT NULL,

View File

@@ -1,4 +1,6 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
PRAGMA foreign_keys = OFF;
--> statement-breakpoint
CREATE TABLE `__new_document` (
`name` text PRIMARY KEY NOT NULL,
`last_modified` integer NOT NULL,
@@ -6,8 +8,31 @@ CREATE TABLE `__new_document` (
`size` integer NOT NULL,
`perm` text NOT NULL
);
--> statement-breakpoint
INSERT INTO
`__new_document`(
"name",
"last_modified",
"contentType",
"size",
"perm"
)
SELECT
"name",
"last_modified",
"contentType",
"size",
"perm"
FROM
`document`;
--> statement-breakpoint
DROP TABLE `document`;
--> statement-breakpoint
ALTER TABLE
`__new_document` RENAME TO `document`;
--> statement-breakpoint
INSERT INTO `__new_document`("name", "last_modified", "contentType", "size", "perm") SELECT "name", "last_modified", "contentType", "size", "perm" FROM `document`;--> statement-breakpoint
DROP TABLE `document`;--> statement-breakpoint
ALTER TABLE `__new_document` RENAME TO `document`;--> statement-breakpoint
PRAGMA foreign_keys = ON;

View File

@@ -1 +1,2 @@
ALTER TABLE `document` RENAME COLUMN "contentType" TO "content_type";
ALTER TABLE
`document` RENAME COLUMN "contentType" TO "content_type";

View File

@@ -1 +1,4 @@
ALTER TABLE `document` ADD `content` text;
ALTER TABLE
`document`
ADD
`content` text;

View File

@@ -1 +1,4 @@
ALTER TABLE `document` ALTER COLUMN "content" TO "content" text NOT NULL;
ALTER TABLE
`document`
ALTER COLUMN
"content" TO "content" text NOT NULL;

View File

@@ -1 +1,4 @@
ALTER TABLE `document` ALTER COLUMN "content" TO "content" text;
ALTER TABLE
`document`
ALTER COLUMN
"content" TO "content" text;

View File

@@ -3,10 +3,15 @@ CREATE TABLE `cache` (
`key` text PRIMARY KEY NOT NULL,
`json` text,
`binary` blob,
`created_at` integer DEFAULT (current_timestamp),
`created_at` integer DEFAULT (CURRENT_TIMESTAMP),
`expires_at` integer
);
--> statement-breakpoint
CREATE INDEX `key_idx` ON `cache` (`key`);
--> statement-breakpoint
CREATE INDEX `scope_idx` ON `cache` (`scope`);
--> statement-breakpoint
CREATE INDEX `key_idx` ON `cache` (`key`);--> statement-breakpoint
CREATE INDEX `scope_idx` ON `cache` (`scope`);--> statement-breakpoint
CREATE INDEX `name_idx` ON `document` (`name`);

View File

@@ -0,0 +1,2 @@
ALTER TABLE
`image` RENAME COLUMN "blurhash" TO "thumbhash";

View File

@@ -0,0 +1,22 @@
DROP INDEX "key_idx";
--> statement-breakpoint
DROP INDEX "scope_idx";
--> statement-breakpoint
DROP INDEX "name_idx";
--> statement-breakpoint
ALTER TABLE
`image`
ALTER COLUMN
"created_at" TO "created_at" integer DEFAULT (unixepoch());
--> statement-breakpoint
CREATE INDEX `key_idx` ON `cache` (`key`);
--> statement-breakpoint
CREATE INDEX `scope_idx` ON `cache` (`scope`);
--> statement-breakpoint
CREATE INDEX `name_idx` ON `document` (`name`);

View File

@@ -0,0 +1,311 @@
{
"version": "6",
"dialect": "sqlite",
"id": "685b57ca-45e0-4373-baee-fc3abb4f2d74",
"prevId": "5694a345-e55c-4aa3-9f29-1045b28f5203",
"tables": {
"cache": {
"name": "cache",
"columns": {
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"json": {
"name": "json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"binary": {
"name": "binary",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"key_idx": {
"name": "key_idx",
"columns": [
"key"
],
"isUnique": false
},
"scope_idx": {
"name": "scope_idx",
"columns": [
"scope"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"document": {
"name": "document",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"last_modified": {
"name": "last_modified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"perm": {
"name": "perm",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"name_idx": {
"name": "name_idx",
"columns": [
"name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"image": {
"name": "image",
"columns": {
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"average": {
"name": "average",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbhash": {
"name": "thumbhash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime": {
"name": "mime",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"performance": {
"name": "performance",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"search": {
"name": "search",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(STRFTIME('%s', 'now') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"image\".\"blurhash\"": "\"image\".\"thumbhash\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,309 @@
{
"version": "6",
"dialect": "sqlite",
"id": "11bbbc9d-3c0c-4fb9-893f-c87d5b8660d0",
"prevId": "685b57ca-45e0-4373-baee-fc3abb4f2d74",
"tables": {
"cache": {
"name": "cache",
"columns": {
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"json": {
"name": "json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"binary": {
"name": "binary",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"key_idx": {
"name": "key_idx",
"columns": [
"key"
],
"isUnique": false
},
"scope_idx": {
"name": "scope_idx",
"columns": [
"scope"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"document": {
"name": "document",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"last_modified": {
"name": "last_modified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"perm": {
"name": "perm",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"name_idx": {
"name": "name_idx",
"columns": [
"name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"image": {
"name": "image",
"columns": {
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(unixepoch())"
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"average": {
"name": "average",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbhash": {
"name": "thumbhash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime": {
"name": "mime",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"performance": {
"name": "performance",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"search": {
"name": "search",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(STRFTIME('%s', 'now') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -71,6 +71,20 @@
"when": 1736172911816,
"tag": "0009_free_robin_chapel",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762099260474,
"tag": "0010_youthful_tyrannus",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1768058169808,
"tag": "0011_reflective_frank_castle",
"breakpoints": true
}
]
}

View File

@@ -1,5 +0,0 @@
import { defineConfig } from "$fresh/server.ts";
import tailwind from "$fresh/plugins/tailwind.ts";
export default defineConfig({
plugins: [tailwind()],
});

View File

@@ -1,138 +0,0 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $_layout from "./routes/_layout.tsx";
import * as $_middleware from "./routes/_middleware.ts";
import * as $admin_cache_index from "./routes/admin/cache/index.tsx";
import * as $admin_log_index from "./routes/admin/log/index.tsx";
import * as $admin_performance_index from "./routes/admin/performance/index.tsx";
import * as $api_articles_name_ from "./routes/api/articles/[name].ts";
import * as $api_articles_create_index from "./routes/api/articles/create/index.ts";
import * as $api_articles_index from "./routes/api/articles/index.ts";
import * as $api_auth_callback from "./routes/api/auth/callback.ts";
import * as $api_auth_login from "./routes/api/auth/login.ts";
import * as $api_auth_logout from "./routes/api/auth/logout.ts";
import * as $api_cache from "./routes/api/cache.ts";
import * as $api_images_index from "./routes/api/images/index.ts";
import * as $api_index from "./routes/api/index.ts";
import * as $api_logs from "./routes/api/logs.ts";
import * as $api_movies_name_ from "./routes/api/movies/[name].ts";
import * as $api_movies_enhance_name_ from "./routes/api/movies/enhance/[name].ts";
import * as $api_movies_index from "./routes/api/movies/index.ts";
import * as $api_query_index from "./routes/api/query/index.ts";
import * as $api_recipes_name_ from "./routes/api/recipes/[name].ts";
import * as $api_recipes_create_index from "./routes/api/recipes/create/index.ts";
import * as $api_recipes_create_parseJsonLd from "./routes/api/recipes/create/parseJsonLd.ts";
import * as $api_recipes_index from "./routes/api/recipes/index.ts";
import * as $api_recommendation_all from "./routes/api/recommendation/all.ts";
import * as $api_recommendation_data from "./routes/api/recommendation/data.ts";
import * as $api_recommendation_index from "./routes/api/recommendation/index.ts";
import * as $api_recommendation_movie_id_ from "./routes/api/recommendation/movie/[id].ts";
import * as $api_series_name_ from "./routes/api/series/[name].ts";
import * as $api_series_enhance_name_ from "./routes/api/series/enhance/[name].ts";
import * as $api_series_index from "./routes/api/series/index.ts";
import * as $api_tmdb_id_ from "./routes/api/tmdb/[id].ts";
import * as $api_tmdb_credits_id_ from "./routes/api/tmdb/credits/[id].ts";
import * as $api_tmdb_query from "./routes/api/tmdb/query.ts";
import * as $articles_name_ from "./routes/articles/[name].tsx";
import * as $articles_index from "./routes/articles/index.tsx";
import * as $index from "./routes/index.tsx";
import * as $movies_name_ from "./routes/movies/[name].tsx";
import * as $movies_index from "./routes/movies/index.tsx";
import * as $recipes_name_ from "./routes/recipes/[name].tsx";
import * as $recipes_index from "./routes/recipes/index.tsx";
import * as $series_name_ from "./routes/series/[name].tsx";
import * as $series_index from "./routes/series/index.tsx";
import * as $Counter from "./islands/Counter.tsx";
import * as $IngredientsList from "./islands/IngredientsList.tsx";
import * as $KMenu from "./islands/KMenu.tsx";
import * as $KMenu_commands from "./islands/KMenu/commands.ts";
import * as $KMenu_commands_add_movie_infos from "./islands/KMenu/commands/add_movie_infos.ts";
import * as $KMenu_commands_add_series_infos from "./islands/KMenu/commands/add_series_infos.ts";
import * as $KMenu_commands_create_article from "./islands/KMenu/commands/create_article.ts";
import * as $KMenu_commands_create_movie from "./islands/KMenu/commands/create_movie.ts";
import * as $KMenu_commands_create_recipe from "./islands/KMenu/commands/create_recipe.ts";
import * as $KMenu_commands_create_recommendations from "./islands/KMenu/commands/create_recommendations.ts";
import * as $KMenu_commands_create_series from "./islands/KMenu/commands/create_series.ts";
import * as $KMenu_types from "./islands/KMenu/types.ts";
import * as $Link from "./islands/Link.tsx";
import * as $Recommendations from "./islands/Recommendations.tsx";
import * as $Search from "./islands/Search.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
"./routes/_layout.tsx": $_layout,
"./routes/_middleware.ts": $_middleware,
"./routes/admin/cache/index.tsx": $admin_cache_index,
"./routes/admin/log/index.tsx": $admin_log_index,
"./routes/admin/performance/index.tsx": $admin_performance_index,
"./routes/api/articles/[name].ts": $api_articles_name_,
"./routes/api/articles/create/index.ts": $api_articles_create_index,
"./routes/api/articles/index.ts": $api_articles_index,
"./routes/api/auth/callback.ts": $api_auth_callback,
"./routes/api/auth/login.ts": $api_auth_login,
"./routes/api/auth/logout.ts": $api_auth_logout,
"./routes/api/cache.ts": $api_cache,
"./routes/api/images/index.ts": $api_images_index,
"./routes/api/index.ts": $api_index,
"./routes/api/logs.ts": $api_logs,
"./routes/api/movies/[name].ts": $api_movies_name_,
"./routes/api/movies/enhance/[name].ts": $api_movies_enhance_name_,
"./routes/api/movies/index.ts": $api_movies_index,
"./routes/api/query/index.ts": $api_query_index,
"./routes/api/recipes/[name].ts": $api_recipes_name_,
"./routes/api/recipes/create/index.ts": $api_recipes_create_index,
"./routes/api/recipes/create/parseJsonLd.ts":
$api_recipes_create_parseJsonLd,
"./routes/api/recipes/index.ts": $api_recipes_index,
"./routes/api/recommendation/all.ts": $api_recommendation_all,
"./routes/api/recommendation/data.ts": $api_recommendation_data,
"./routes/api/recommendation/index.ts": $api_recommendation_index,
"./routes/api/recommendation/movie/[id].ts": $api_recommendation_movie_id_,
"./routes/api/series/[name].ts": $api_series_name_,
"./routes/api/series/enhance/[name].ts": $api_series_enhance_name_,
"./routes/api/series/index.ts": $api_series_index,
"./routes/api/tmdb/[id].ts": $api_tmdb_id_,
"./routes/api/tmdb/credits/[id].ts": $api_tmdb_credits_id_,
"./routes/api/tmdb/query.ts": $api_tmdb_query,
"./routes/articles/[name].tsx": $articles_name_,
"./routes/articles/index.tsx": $articles_index,
"./routes/index.tsx": $index,
"./routes/movies/[name].tsx": $movies_name_,
"./routes/movies/index.tsx": $movies_index,
"./routes/recipes/[name].tsx": $recipes_name_,
"./routes/recipes/index.tsx": $recipes_index,
"./routes/series/[name].tsx": $series_name_,
"./routes/series/index.tsx": $series_index,
},
islands: {
"./islands/Counter.tsx": $Counter,
"./islands/IngredientsList.tsx": $IngredientsList,
"./islands/KMenu.tsx": $KMenu,
"./islands/KMenu/commands.ts": $KMenu_commands,
"./islands/KMenu/commands/add_movie_infos.ts":
$KMenu_commands_add_movie_infos,
"./islands/KMenu/commands/add_series_infos.ts":
$KMenu_commands_add_series_infos,
"./islands/KMenu/commands/create_article.ts":
$KMenu_commands_create_article,
"./islands/KMenu/commands/create_movie.ts": $KMenu_commands_create_movie,
"./islands/KMenu/commands/create_recipe.ts": $KMenu_commands_create_recipe,
"./islands/KMenu/commands/create_recommendations.ts":
$KMenu_commands_create_recommendations,
"./islands/KMenu/commands/create_series.ts": $KMenu_commands_create_series,
"./islands/KMenu/types.ts": $KMenu_types,
"./islands/Link.tsx": $Link,
"./islands/Recommendations.tsx": $Recommendations,
"./islands/Search.tsx": $Search,
},
baseUrl: import.meta.url,
} satisfies Manifest;
export default manifest;

View File

@@ -1,6 +1,6 @@
import type { Signal } from "@preact/signals";
import { Button } from "@components/Button.tsx";
import { IconCircleMinus, IconCirclePlus } from "@components/icons.tsx";
import { TbCircleMinus, TbCirclePlus } from "@preact-icons/tb";
interface CounterProps {
count: Signal<number>;
@@ -10,21 +10,21 @@ export default function Counter(props: CounterProps) {
props.count.value = Math.max(1, props.count.value);
return (
<div class="flex items-center px-1 py-2 rounded-xl">
<Button
class=""
onClick={() => props.count.value -= 1}
>
<IconCircleMinus />
<Button onClick={() => props.count.value -= 1}>
<TbCircleMinus class="h-6 w-6" />
</Button>
<input
class="text-3xl bg-transparent inline text-center -mx-4"
type="number"
size={props.count.toString().length}
value={props.count}
onInput={(ev) => props.count.value = ev.target?.value}
onInput={(ev) => {
const target = ev.target as HTMLInputElement;
props.count.value = Math.max(1, Number(target.value));
}}
/>
<Button onClick={() => props.count.value += 1}>
<IconCirclePlus />
<TbCirclePlus class="h-6 w-6" />
</Button>
</div>
);

View File

@@ -1,23 +1,19 @@
import { Signal } from "@preact/signals";
import type { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
import { FunctionalComponent } from "preact";
import { unitsOfMeasure } from "@lib/parseIngredient.ts";
function formatAmount(num: number) {
if (num === 0) return "";
return (Math.floor(num * 4) / 4).toString();
return (Math.round(num * 4) / 4).toString();
}
function formatUnit(unit: string, amount: number) {
const unitKey = unit.toLowerCase() as keyof typeof unitsOfMeasure;
if (!unit) return "";
const unitKey = unit.toLowerCase() as (keyof typeof unitsOfMeasure);
if (unitKey in unitsOfMeasure) {
if (amount > 1 && unitsOfMeasure[unitKey].plural !== undefined) {
return unitsOfMeasure[unitKey].plural;
}
if (unitKey !== "cup") {
return unitsOfMeasure[unitKey].short;
}
return unitKey.toString();
} else {
return unit;
@@ -42,52 +38,39 @@ const Ingredient = (
return (
<tr key={key}>
<td class="pr-4 py-2">
<td class="pr-4 py-1">
{formatAmount(finalAmount || 0)}
<span class="ml-0.5 opacity-50">
{formatUnit(unit, finalAmount || 0)}
</span>
</td>
<td class="px-4 py-2">{name}</td>
<td class="px-4 py-1">{name}</td>
</tr>
);
};
export const IngredientsList: FunctionalComponent<
{
export const IngredientsList = (
{ ingredients, amount, portion }: {
ingredients: (Ingredient | IngredientGroup)[];
amount: Signal<number>;
portion?: number;
}
> = (
{ ingredients, amount, portion },
},
) => {
return (
<table class="w-full border-collapse table-auto">
<tbody>
{ingredients.map((item, index) => {
{ingredients.map((item) => {
if ("items" in item) {
// Render IngredientGroup
const { name, items: groupIngredients } = item as IngredientGroup;
return (
<>
<tr key={index}>
<td colSpan={3} class="pr-4 py-2 font-italic">{name}</td>
</tr>
{groupIngredients.map((item, index) => {
// Render Ingredient
return item.items.map((ing, i) => {
return (
<Ingredient
key={index}
ingredient={item}
key={i}
ingredient={ing}
amount={amount}
portion={portion}
/>
);
})}
</>
);
});
} else {
return (
<Ingredient ingredient={item} amount={amount} portion={portion} />

View File

@@ -4,7 +4,7 @@ import { useEventListener } from "@lib/hooks/useEventListener.ts";
import { menus } from "@islands/KMenu/commands.ts";
import { MenuEntry } from "@islands/KMenu/types.ts";
import * as icons from "@components/icons.tsx";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { isKMenuOpen } from "@lib/kmenu.ts";
const KMenuEntry = (
{ entry, activeIndex, index }: {
entry: MenuEntry;
@@ -21,7 +21,7 @@ const KMenuEntry = (
: "text-gray-400"
}`}
>
{entry?.icon && icons[entry.icon]({ class: "w-4 h-4 mr-1" })}
{entry?.icon && icons[entry.icon]({ class: "min-w-4 h-4 mr-1" })}
{entry.title}
</div>
);
@@ -42,7 +42,7 @@ export const KMenu = (
const input = useRef<HTMLInputElement>(null);
const commandInput = useSignal("");
const visible = useSignal(false);
const visible = isKMenuOpen;
if (visible.value === false) {
setTimeout(() => {
activeMenuType.value = "main";
@@ -103,8 +103,9 @@ export const KMenu = (
}
useEventListener("keydown", (ev: KeyboardEvent) => {
const target = ev.target as HTMLElement;
if (ev.key === "k") {
if (ev?.target?.nodeName == "INPUT") {
if (target.nodeName == "INPUT") {
return;
}
@@ -153,25 +154,27 @@ export const KMenu = (
} else {
input.current?.blur();
}
}, IS_BROWSER ? document?.body : undefined);
}, typeof document !== "undefined" ? document?.body : undefined);
return (
<div
class={`${visible.value ? "opacity-100" : "opacity-0"} pointer-events-${
visible.value ? "auto" : "none"
class={`${visible.value ? "opacity-100" : "opacity-0"} ${
visible.value ? "pointer-events-auto" : "pointer-events-none"
} transition grid place-items-center w-full h-full fixed top-0 left-0 z-50`}
style={{ background: "#141217ee" }}
>
<div
class={`relative w-1/2 max-h-64 max-w-[400px] rounded-2xl shadow-2xl nnoisy-gradient overflow-hidden after:opacity-10 bg-gray-700`}
class={`relative w-1/2 max-h-64 max-w-100 rounded-2xl shadow-2xl nnoisy-gradient overflow-hidden after:opacity-10 bg-gray-700`}
style={{ background: "#2B2930", color: "#818181" }}
>
<div
class={`grid h-12 text-gray-400 ${
activeState.value !== "loading" && "border-b"
class={`grid min-h-12 text-gray-400 ${
(activeState.value === "normal" || activeState.value === "input") &&
"border-b"
} border-gray-500 `}
style={{
gridTemplateColumns: activeState.value !== "loading"
gridTemplateColumns:
(activeState.value === "normal" || activeState.value === "input")
? "auto 1fr"
: "1fr",
}}
@@ -197,12 +200,18 @@ export const KMenu = (
)}
{activeState.value === "loading" && (
<div class="py-3 px-4 flex items-center gap-2">
<icons.IconLoader2 class="animate-spin w-4 h-4" />
<icons.IconLoader2 class="animate-spin min-w-4 h-4" />
{loadingText.value || "Loading..."}
</div>
)}
{activeState.value === "error" && (
<div class="py-3 px-4 flex items-center gap-2 text-red-400">
<icons.IconAlertCircle class="min-w-4 h-4" />
{loadingText.value || "An error occurred"}
</div>
{activeState.value === "normal" &&
)}
</div>
{(activeState.value === "normal" || activeState.value === "input") &&
(
<div
class=""

View File

@@ -5,8 +5,8 @@ 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";
import { createNewSeries } from "@islands/KMenu/commands/create_series.ts";
import { updateAllRecommendations } from "@islands/KMenu/commands/create_recommendations.ts";
import { createNewRecipe } from "@islands/KMenu/commands/create_recipe.ts";
import { enhanceArticleInfo } from "@islands/KMenu/commands/enhance_article_infos.ts";
export const menus: Record<string, Menu> = {
main: {
@@ -77,6 +77,7 @@ export const menus: Record<string, Menu> = {
createNewSeries,
createNewRecipe,
addMovieInfos,
enhanceArticleInfo,
// updateAllRecommendations,
],
},

View File

@@ -1,15 +1,16 @@
import { Movie } from "@lib/resource/movies.ts";
import { TMDBMovie } from "@lib/types.ts";
import { getCookie } from "@lib/string.ts";
import { MenuEntry } from "../types.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const addMovieInfos: MenuEntry = {
title: "Add Movie infos",
meta: "",
icon: "IconReportSearch",
cb: async (state, context) => {
try {
state.activeState.value = "loading";
const movie = context as Movie;
const movie = context as ReviewResource;
const query = movie.name;
@@ -17,6 +18,10 @@ export const addMovieInfos: MenuEntry = {
`/api/tmdb/query?q=${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error(await response.text());
}
const json = await response.json() as TMDBMovie[];
const menuID = `result/${movie.name}`;
@@ -26,6 +31,7 @@ export const addMovieInfos: MenuEntry = {
entries: json.map((m) => ({
title: `${m.title} released ${m.release_date}`,
cb: async () => {
try {
state.activeState.value = "loading";
await fetch(`/api/movies/enhance/${movie.name}/`, {
method: "POST",
@@ -33,7 +39,15 @@ export const addMovieInfos: MenuEntry = {
});
state.visible.value = false;
state.activeState.value = "normal";
window.location.reload();
globalThis.location.reload();
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
})),
};
@@ -41,6 +55,14 @@ export const addMovieInfos: MenuEntry = {
state.activeMenu.value = menuID;
state.commandInput.value = "";
state.activeState.value = "normal";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
visible: () => {
const loc = globalThis["location"];

View File

@@ -1,15 +1,16 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { TMDBSeries } from "@lib/types.ts";
import { getCookie } from "@lib/string.ts";
import { Series } from "@lib/resource/series.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const addSeriesInfo: MenuEntry = {
title: "Add Series infos",
meta: "",
icon: "IconReportSearch",
cb: async (state, context) => {
try {
state.activeState.value = "loading";
const series = context as Series;
const series = context as ReviewResource;
const query = series.name;
@@ -17,6 +18,10 @@ export const addSeriesInfo: MenuEntry = {
`/api/tmdb/query?q=${encodeURIComponent(query)}&type=serie`,
);
if (!response.ok) {
throw new Error(await response.text());
}
const json = await response.json() as TMDBSeries[];
const menuID = `result/${series.name}`;
@@ -26,6 +31,7 @@ export const addSeriesInfo: MenuEntry = {
entries: json.map((m) => ({
title: `${m.name || m.original_name} released ${m.first_air_date}`,
cb: async () => {
try {
state.activeState.value = "loading";
await fetch(`/api/series/enhance/${series.name}/`, {
method: "POST",
@@ -34,6 +40,14 @@ export const addSeriesInfo: MenuEntry = {
state.visible.value = false;
state.activeState.value = "normal";
//window.location.reload();
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
})),
};
@@ -41,6 +55,14 @@ export const addSeriesInfo: MenuEntry = {
state.commandInput.value = "";
state.activeMenu.value = menuID;
state.activeState.value = "normal";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
visible: () => {
const loc = globalThis["location"];

View File

@@ -22,14 +22,16 @@ export const createNewArticle: MenuEntry = {
state.activeState.value = "loading";
fetchStream("/api/articles/create?url=" + value, (chunk) => {
if (chunk.startsWith("id:")) {
if (chunk.type === "error") {
state.activeState.value = "error";
state.loadingText.value = chunk.message;
} else if (chunk.type === "finished") {
state.loadingText.value = "Finished";
setTimeout(() => {
window.location.href = "/articles/" +
chunk.replace("id:", "").trim();
globalThis.location.href = "/articles/" + chunk.url;
}, 500);
} else {
state.loadingText.value = chunk;
state.loadingText.value = chunk.message;
}
});
}

View File

@@ -1,8 +1,8 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { TMDBMovie } from "@lib/types.ts";
import { debounce } from "@lib/helpers.ts";
import { Movie } from "@lib/resource/movies.ts";
import { getCookie } from "@lib/string.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const createNewMovie: MenuEntry = {
title: "Create new movie",
@@ -31,6 +31,7 @@ export const createNewMovie: MenuEntry = {
let currentQuery: string;
const search = debounce(async function search(query: string) {
try {
currentQuery = query;
if (query.length < 2) {
return;
@@ -38,6 +39,10 @@ export const createNewMovie: MenuEntry = {
const response = await fetch("/api/tmdb/query?q=" + query);
if (!response.ok) {
throw new Error(await response.text());
}
const movies = await response.json() as TMDBMovie[];
if (query !== currentQuery) return;
@@ -48,18 +53,38 @@ export const createNewMovie: MenuEntry = {
return {
title: `${r.title} - ${r.release_date}`,
cb: async () => {
try {
state.activeState.value = "loading";
const response = await fetch("/api/movies/" + r.id, {
method: "POST",
});
const movie = await response.json() as Movie;
if (!response.ok) {
throw new Error(await response.text());
}
const movie = await response.json() as ReviewResource;
unsub();
window.location.href = "/movies/" + movie.name;
globalThis.location.href = "/movies/" + movie.name;
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
};
}),
};
state.activeMenu.value = "input_link";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
}, 500);
const unsub = state.commandInput.subscribe((value) => {

View File

@@ -21,15 +21,17 @@ export const createNewRecipe: MenuEntry = {
state.activeState.value = "loading";
fetchStream("/api/recipes/create?url=" + value, (chunk) => {
if (chunk.startsWith("id:")) {
fetchStream("/api/recipes/create?url=" + value, (msg) => {
if (msg.type === "error") {
state.activeState.value = "error";
state.loadingText.value = msg.message;
} else if (msg.type === "finished") {
state.loadingText.value = "Finished";
setTimeout(() => {
globalThis.location.href = "/recipes/" +
chunk.replace("id:", "").trim();
globalThis.location.href = "/recipes/" + msg.url;
}, 500);
} else {
state.loadingText.value = chunk;
state.loadingText.value = msg.message;
}
});
}

View File

@@ -10,12 +10,15 @@ export const updateAllRecommendations: MenuEntry = {
state.activeState.value = "loading";
fetchStream("/api/recommendation/all", (chunk) => {
if (chunk.toLowerCase().includes("finish")) {
if (chunk.type === "error") {
state.activeState.value = "error";
state.loadingText.value = chunk.message;
} else if (chunk.type === "finished") {
setTimeout(() => {
window.location.reload();
globalThis.location.reload();
}, 500);
} else {
state.loadingText.value = chunk;
state.loadingText.value = chunk.message;
}
});
},

View File

@@ -1,8 +1,8 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { TMDBSeries } from "@lib/types.ts";
import { debounce } from "@lib/helpers.ts";
import { Series } from "@lib/resource/series.ts";
import { getCookie } from "@lib/string.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const createNewSeries: MenuEntry = {
title: "Create new series",
@@ -31,6 +31,7 @@ export const createNewSeries: MenuEntry = {
let currentQuery: string;
const search = debounce(async function search(query: string) {
try {
currentQuery = query;
if (query.length < 2) {
return;
@@ -40,6 +41,10 @@ export const createNewSeries: MenuEntry = {
"/api/tmdb/query?q=" + query + "&type=series",
);
if (!response.ok) {
throw new Error(await response.text());
}
const series = await response.json() as TMDBSeries[];
if (query !== currentQuery) return;
@@ -50,19 +55,39 @@ export const createNewSeries: MenuEntry = {
return {
title: `${r.name} - ${r.first_air_date}`,
cb: async () => {
try {
state.activeState.value = "loading";
const response = await fetch("/api/series/" + r.id, {
method: "POST",
});
const series = await response.json() as Series;
if (!response.ok) {
throw new Error(await response.text());
}
const series = await response.json() as ReviewResource;
unsub();
window.location.href = "/series/" + series.name;
globalThis.location.href = "/series/" + series.name;
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
},
};
}),
};
state.commandInput.value = "";
state.activeMenu.value = "input_link";
} catch (e) {
state.activeState.value = "error";
if (e instanceof Error) {
if ("message" in e) {
state.loadingText.value = e.message;
}
}
}
}, 500);
const unsub = state.commandInput.subscribe((value) => {

View File

@@ -0,0 +1,41 @@
import { getCookie } from "@lib/string.ts";
import { MenuEntry } from "../types.ts";
import { ArticleResource } from "@lib/marka/schema.ts";
import { fetchStream } from "@lib/helpers.ts";
export const enhanceArticleInfo: MenuEntry = {
title: "Enhance Article Info",
meta: "Update metadata and content from source url",
icon: "IconReportSearch",
cb: (state, context) => {
state.activeState.value = "loading";
const article = context as ArticleResource;
fetchStream(
`/api/articles/enhance/${article.name}/`,
(chunk) => {
if (chunk.type === "error") {
state.activeState.value = "error";
state.loadingText.value = chunk.message;
} else if (chunk.type == "finished") {
state.loadingText.value = "Finished";
setTimeout(() => {
state.visible.value = false;
state.activeState.value = "normal";
globalThis.location.reload();
}, 500);
} else {
state.loadingText.value = chunk.message;
}
},
{ method: "POST" },
);
},
visible: () => {
const loc = globalThis["location"];
if (!getCookie("session_cookie")) return false;
return (loc?.pathname?.includes("article") &&
!loc.pathname.endsWith("articles"));
},
};

14
islands/KMenuButton.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Button } from "@components/Button.tsx";
import { IconMenu2 } from "@components/icons.tsx";
import { isKMenuOpen } from "@lib/kmenu.ts";
export default function KMenuButton() {
return (
<Button
class="fixed bottom-4 right-4 md:hidden bg-gray-800 text-white p-3 rounded-full shadow-lg z-50"
onClick={() => (isKMenuOpen.value = true)}
>
<IconMenu2 class="w-6 h-6" />
</Button>
);
}

View File

@@ -1,18 +1,20 @@
import { useEffect } from "preact/hooks";
declare global {
// deno-lint-ignore no-var
var loadingTimeout: ReturnType<typeof setTimeout> | undefined;
}
export function Link(
{ href, children, class: _class, style }: {
props: {
href?: string;
class?: string;
style?: preact.JSX.CSSProperties;
style?: preact.CSSProperties;
children: preact.ComponentChildren;
"data-thumb"?: string;
},
) {
const { href, children, class: _class, style } = props;
const thumbhash = props["data-thumb"];
function handleClick() {
if (globalThis.loadingTimeout) {
return;
@@ -27,9 +29,11 @@ export function Link(
clearTimeout(globalThis.loadingTimeout);
delete globalThis.loadingTimeout;
setTimeout(() => {
globalThis.dispatchEvent(new CustomEvent("loading-finished"));
document.querySelector("main")?.classList.remove("loading");
}, 100);
} else {
globalThis.dispatchEvent(new CustomEvent("loading-finished"));
document.querySelector("main")?.classList.remove("loading");
}
});
@@ -39,6 +43,7 @@ export function Link(
href={href}
style={style}
onClick={handleClick}
data-thumb={thumbhash}
class={_class}
>
{children}

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from "preact/hooks";
import { IconWand } from "@components/icons.tsx";
import { RecommendationResource } from "@lib/recommendation.ts";
type RecommendationState = "disabled" | "loading";
@@ -11,7 +12,7 @@ export function Recommendations(
},
) {
const [state, setState] = useState<RecommendationState>("disabled");
const [results, setResults] = useState();
const [results, setResults] = useState<RecommendationResource[]>();
const startFetch = useCallback(
async () => {
@@ -44,9 +45,9 @@ export function Recommendations(
<div class="flex gap-5 items-center mb-4">
<img
class="w-12 h-12 rounded-full object-cover"
src={`https://image.tmdb.org/t/p/original${res.poster_path}`}
src={`https://image.tmdb.org/t/p/original${res.id}`}
/>
<p>{res.title}</p>
<p>{res.id}</p>
</div>
);
})}
@@ -66,6 +67,7 @@ export function Recommendations(
{!results && state === "disabled" &&
(
<button
type="submit"
onClick={startFetch}
class="ml-8 mt-2 font-thin flex items-center gap-2 text-white my-2"
>

View File

@@ -2,15 +2,15 @@ import { useEffect, useRef } from "preact/hooks";
import useDebouncedCallback from "@lib/hooks/useDebouncedCallback.ts";
import { IconLoader2, IconSearch } from "@components/icons.tsx";
import { useEventListener } from "@lib/hooks/useEventListener.ts";
import { GenericResource } from "@lib/types.ts";
import { resources } from "@lib/resources.ts";
import { getCookie } from "@lib/string.ts";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { IS_BROWSER } from "fresh/runtime";
import Checkbox from "@components/Checkbox.tsx";
import { Rating } from "@components/Rating.tsx";
import { useSignal } from "@preact/signals";
import Image from "@components/Image.tsx";
import { Emoji } from "@components/Emoji.tsx";
import { GenericResource, getNameOfResource } from "@lib/marka/schema.ts";
export async function fetchQueryResource(url: URL, type = "") {
const query = url.searchParams.get("q");
@@ -23,7 +23,7 @@ export async function fetchQueryResource(url: URL, type = "") {
url.searchParams.set("status", "not-seen");
}
if (type) {
url.searchParams.set("types", type);
url.searchParams.set("type", type);
}
const response = await fetch(url);
const jsonData = await response.json();
@@ -33,28 +33,30 @@ export async function fetchQueryResource(url: URL, type = "") {
}
}
export const RedirectSearchHandler = () => {
if (getCookie("session_cookie")) {
export function RedirectSearchHandler() {
useEventListener("keydown", (e: KeyboardEvent) => {
if (e?.target?.nodeName == "INPUT") return;
if (getCookie("session_cookie")) {
const target = e.target as HTMLInputElement;
if (target.nodeName == "INPUT") return;
if (
e.key === "?" &&
globalThis.location.search === ""
) {
globalThis.location.href += "?q=";
}
}, IS_BROWSER ? document?.body : undefined);
}
}, IS_BROWSER ? document?.body : undefined);
// deno-lint-ignore jsx-no-useless-fragment
return <></>;
};
}
const SearchResultImage = ({ src }: { src: string }) => {
return (
<Image
class="object-cover w-12 h-12 rounded-full"
width="50"
height="50"
width={100}
height={100}
src={src}
alt="preview image"
/>
@@ -67,8 +69,9 @@ export const SearchResultItem = (
showEmoji?: boolean;
},
) => {
const resourceType = resources[item.type];
const href = resourceType ? `${resourceType.link}/${item.id}` : "";
const resourceType =
resources[item.content._type.toLowerCase() as keyof typeof resources];
const href = item?.path.replace("/resources", "").replace(/\.md$/, "");
return (
<a
href={href}
@@ -77,8 +80,8 @@ export const SearchResultItem = (
{showEmoji && resourceType
? <Emoji class="w-7 h-7" name={resourceType.emoji} />
: ""}
{item.meta?.image && <SearchResultImage src={item.meta?.image} />}
{item?.name}
{item.image && <SearchResultImage src={item.image?.url} />}
{getNameOfResource(item)}
</a>
);
};
@@ -111,6 +114,7 @@ const Search = (
const searchQuery = useSignal(q);
const data = useSignal<GenericResource[] | undefined>(results);
const isLoading = useSignal(false);
const rating = useSignal<number | undefined>(undefined);
const showSeenStatus = useSignal(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -119,8 +123,10 @@ const Search = (
if (u.searchParams.get("q") !== searchQuery.value) {
u.searchParams.set("q", searchQuery.value);
}
if (showSeenStatus.value) {
if (showSeenStatus.value === true) {
u.searchParams.set("rating", "0");
} else if (rating.value) {
u.searchParams.set("rating", rating.value.toString());
} else {
u.searchParams.delete("rating");
}
@@ -159,7 +165,7 @@ const Search = (
useEffect(() => {
debouncedFetchData(); // Call the debounced fetch function with the updated search query
}, [searchQuery.value, showSeenStatus.value]);
}, [searchQuery.value, showSeenStatus.value, rating.value]);
useEffect(() => {
debouncedFetchData();
@@ -184,8 +190,12 @@ const Search = (
onInput={handleInputChange}
/>
</div>
<Checkbox label="seen" checked={showSeenStatus} />
<Rating rating={4} />
<Checkbox label="unrated" checked={showSeenStatus} />
<div
class={showSeenStatus.value ? "opacity-10" : ""}
>
<Rating rating={rating} />
</div>
</header>
{data.value?.length && !isLoading.value
? <SearchResultList showEmoji={!type} result={data.value} />

View File

@@ -1,4 +1,4 @@
import { OAuth2Client } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts";
import { OAuth2Client } from "@cmd-johnson/oauth2-client";
import {
GITEA_CLIENT_ID,
GITEA_CLIENT_SECRET,

View File

@@ -6,9 +6,12 @@ interface SetCacheOptions {
expires?: number; // Override expiration for individual cache entries
}
const caches = new Map<
export const caches = new Map<
string,
{ info: () => { name: string; count: number; sizeInKB: number } }
{
info: () => { name: string; count: number; sizeInKB: number };
clear: () => void;
}
>();
export function createCache<T>(
@@ -31,6 +34,10 @@ export function createCache<T>(
return entry.value; // Return value if not expired
},
clear() {
cache.clear();
},
set(key: string, value: T | unknown, opts: SetCacheOptions = {}) {
const now = Date.now();
const expiresIn = opts.expires ?? createOpts.expires;
@@ -94,6 +101,7 @@ export function createCache<T>(
};
caches.set(cacheName, {
clear: api.clear.bind(api),
info: api.info.bind(api),
});

View File

@@ -1,156 +0,0 @@
import {
createDocument,
getDocument,
getDocuments,
transformDocument,
} from "@lib/documents.ts";
import { Root } from "https://esm.sh/remark-frontmatter@4.0.1";
import { GenericResource } from "@lib/types.ts";
import { parseRating } from "@lib/helpers.ts";
import { isLocalImage } from "@lib/string.ts";
import { SILVERBULLET_SERVER } from "@lib/env.ts";
import { imageTable } from "@lib/db/schema.ts";
import { db } from "@lib/db/sqlite.ts";
import { eq } from "drizzle-orm/sql";
import { createCache } from "@lib/cache.ts";
export async function addThumbnailToResource<T extends GenericResource>(
res: T,
): Promise<T> {
if (!res?.meta?.image) return res;
const imageUrl = isLocalImage(res.meta.image)
? `${SILVERBULLET_SERVER}/${res.meta.image}`
: res.meta.image;
const image = await db.select().from(imageTable)
.where(eq(imageTable.url, imageUrl))
.limit(1)
.then((images) => images[0]);
if (image) {
return {
...res,
meta: {
...res.meta,
average: image.average,
thumbnail: image.blurhash,
},
};
}
return res;
}
type SortType = "rating" | "date" | "name" | "author";
function sortFunction<T extends GenericResource>(sortType: SortType) {
return (a: T, b: T) => {
switch (sortType) {
case "rating":
return parseRating(a.meta?.rating || 0) >
parseRating(b.meta?.rating || 0)
? -1
: 1;
case "date":
return (a.meta?.date || 0) > (b.meta?.date || 0) ? -1 : 1;
case "name":
return a.name.localeCompare(b.name);
case "author":
return a.meta?.author?.localeCompare(b.meta?.author || "") || 0;
default:
return 0;
}
};
}
export function createCrud<T extends GenericResource>(
{ prefix, parse, render, hasThumbnails = false }: {
prefix: string;
hasThumbnails?: boolean;
render?: (doc: T) => string;
parse: (doc: string, id: string) => T;
},
) {
const cache = createCache<T>(`crud/${prefix}`, { expires: 60 * 1000 });
function pathFromId(id: string) {
return `${prefix}${id.replaceAll(":", "")}.md`;
}
async function read(id: string) {
const path = pathFromId(id);
if (cache.has(path)) {
return cache.get(path);
}
const content = await getDocument(path);
if (!content) {
return;
}
let parsed = parse(content, id);
if (hasThumbnails) {
parsed = await addThumbnailToResource(parsed);
}
const doc = { ...parsed, content };
cache.set(path, doc, { expires: 10 * 1000 });
return doc;
}
function create(id: string, content: string | ArrayBuffer | T) {
const path = pathFromId(id);
cache.set("all", undefined);
if (
typeof content === "string" || content instanceof ArrayBuffer
) {
return createDocument(path, content);
}
if (render) {
const rendered = render(content);
cache.set(path, content);
return createDocument(path, rendered);
}
throw new Error("No renderer defined for " + prefix + " CRUD");
}
async function update(id: string, updater: (r: Root) => Root) {
const path = pathFromId(id);
const content = await getDocument(path);
if (!content) {
return;
}
const newDoc = transformDocument(content, updater);
await createDocument(path, newDoc);
}
async function readAll({ sort = "rating" }: { sort?: SortType } = {}) {
if (cache.has("all")) {
return cache.get("all") as unknown as T[];
}
const allDocuments = await getDocuments();
const parsed = (await Promise.all(
allDocuments.filter((d) => {
return d.name.startsWith(prefix) &&
d.contentType === "text/markdown" &&
!d.name.endsWith("index.md");
}).map((doc) => {
const id = doc.name.replace(prefix, "").replace(/\.md$/, "");
return read(id);
}),
)).sort(sortFunction<T>(sort)).filter((v) => !!v);
cache.set("all", parsed);
return parsed;
}
return {
read,
readAll,
create,
update,
};
}

View File

@@ -12,7 +12,9 @@ export const userTable = sqliteTable("user", {
id: text()
.primaryKey(),
createdAt: integer("created_at", { mode: "timestamp" })
.default(sql`(current_timestamp)`)
.default(sql`
(CURRENT_TIMESTAMP)
`)
.notNull(),
email: text()
.notNull(),
@@ -24,7 +26,9 @@ export const sessionTable = sqliteTable("session", {
id: text("id")
.primaryKey(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(
sql`(current_timestamp)`,
sql`
(CURRENT_TIMESTAMP)
`,
),
expiresAt: integer("expires_at", { mode: "timestamp" })
.notNull(),
@@ -38,16 +42,20 @@ export const performanceTable = sqliteTable("performance", {
time: int().notNull(),
createdAt: integer("created_at", {
mode: "timestamp_ms",
}).default(sql`(STRFTIME('%s', 'now') * 1000)`),
}).default(sql`
(STRFTIME('%s', 'now') * 1000)
`),
});
export const imageTable = sqliteTable("image", {
createdAt: integer("created_at", { mode: "timestamp" }).default(
sql`(current_timestamp)`,
sql`
(unixepoch())
`,
),
url: text().notNull(),
average: text().notNull(),
blurhash: text().notNull(),
thumbhash: text().notNull(),
mime: text().notNull(),
});
@@ -70,7 +78,9 @@ export const cacheTable = sqliteTable("cache", {
json: text({ mode: "json" }),
binary: blob(),
createdAt: integer("created_at", { mode: "timestamp" }).default(
sql`(current_timestamp)`,
sql`
(CURRENT_TIMESTAMP)
`,
),
expiresAt: integer("expires_at", { mode: "timestamp" }),
}, (table) => {

View File

@@ -1,10 +1,9 @@
import { drizzle } from "drizzle-orm/libsql/node";
import { DATA_DIR } from "@lib/env.ts";
import { drizzle } from "drizzle-orm/libsql";
import path from "node:path";
const DB_FILE = "file:" + path.resolve(DATA_DIR, "db.sqlite");
// You can specify any property from the libsql connection options
export const db = drizzle({
connection: {
url: DB_FILE,

View File

@@ -1,175 +0,0 @@
import { unified } from "https://esm.sh/unified@10.1.2";
import { render } from "gfm";
import "https://esm.sh/prismjs@1.29.0/components/prism-typescript?no-check";
import "https://esm.sh/prismjs@1.29.0/components/prism-bash?no-check";
import "https://esm.sh/prismjs@1.29.0/components/prism-rust?no-check";
import remarkParse from "https://esm.sh/remark-parse@10.0.2";
import remarkStringify from "https://esm.sh/remark-stringify@10.0.3";
import remarkFrontmatter, {
Root,
} from "https://esm.sh/remark-frontmatter@4.0.1";
import { SILVERBULLET_SERVER } from "@lib/env.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
import { createLogger } from "@lib/log/index.ts";
import { db } from "@lib/db/sqlite.ts";
import { documentTable } from "@lib/db/schema.ts";
import { eq } from "drizzle-orm/sql";
export type Document = {
name: string;
lastModified: number;
contentType: string;
content: string | null;
size: number;
perm: string;
};
const log = createLogger("documents");
export async function getDocuments(): Promise<Document[]> {
let documents = await db.select().from(documentTable).all();
if (documents.length) return documents;
const headers = new Headers();
headers.append("Accept", "application/json");
headers.append("X-Sync-Mode", "true");
log.debug("fetching all documents");
const response = await fetch(`${SILVERBULLET_SERVER}/index.json`, {
headers: headers,
});
documents = await response.json();
await db.delete(documentTable);
await db.insert(documentTable).values(documents);
return documents;
}
export function createDocument(
name: string,
content: string | ArrayBuffer,
mediaType?: string,
) {
const headers = new Headers();
if (mediaType) {
headers.append("Content-Type", mediaType);
}
log.info("creating document", { name });
if (typeof content === "string") {
updateDocument(name, content).catch(log.error);
}
return fetch(SILVERBULLET_SERVER + "/" + name, {
body: content,
method: "PUT",
headers,
});
}
async function fetchDocument(name: string) {
log.debug("fetching document", { name });
const headers = new Headers();
headers.append("X-Sync-Mode", "true");
const response = await fetch(SILVERBULLET_SERVER + "/" + name, { headers });
if (response.status === 404) {
return;
}
return response.text();
}
export async function getDocument(name: string): Promise<string | undefined> {
const documents = await db.select().from(documentTable).where(
eq(documentTable.name, name),
).limit(1);
// This updates the document in the background
fetchDocument(name).then((content) => {
if (content) {
updateDocument(name, content);
} else {
db.delete(documentTable).where(eq(documentTable.name, name));
}
}).catch(
log.error,
);
if (documents[0]?.content) return documents[0].content;
const text = await fetchDocument(name);
if (!text) {
db.delete(documentTable).where(eq(documentTable.name, name));
return;
}
await updateDocument(name, text);
return text;
}
export function updateDocument(name: string, content: string) {
return db.update(documentTable).set({
content,
}).where(eq(documentTable.name, name)).run();
}
export function transformDocument(input: string, cb: (r: Root) => Root) {
const out = unified()
.use(remarkParse)
.use(remarkFrontmatter, ["yaml"])
.use(() => (tree) => {
return cb(tree);
})
.use(remarkStringify)
.processSync(input);
return fixRenderedMarkdown(String(out));
}
export function parseDocument(doc: string) {
return unified()
.use(remarkParse)
.use(remarkFrontmatter, ["yaml", "toml"])
.parse(doc);
}
export function renderMarkdown(doc: string) {
return render(doc, {
allowMath: true,
});
}
export type ParsedDocument = ReturnType<typeof parseDocument>;
export type DocumentChild = ParsedDocument["children"][number];
export function findRangeOfChildren(children: DocumentChild[]) {
const firstChild = children[0];
const lastChild = children.length > 1
? children[children.length - 1]
: firstChild;
const start = firstChild.position?.start.offset;
const end = lastChild.position?.end.offset;
if (typeof start !== "number" || typeof end !== "number") return;
return [start, end];
}
export function getTextOfRange(children: DocumentChild[], text: string) {
if (!children || children.length === 0) {
return;
}
const range = findRangeOfChildren(children);
if (!range) return;
return text.substring(range[0], range[1]);
}
export function getTextOfChild(child: DocumentChild): string | undefined {
if ("value" in child) return child.value;
if ("children" in child) {
return getTextOfChild(child.children[0]);
}
return;
}

View File

@@ -4,21 +4,23 @@ export const PROXY_SERVER = Deno.env.get("PROXY_SERVER");
export const PROXY_USERNAME = Deno.env.get("PROXY_USERNAME");
export const PROXY_PASSWORD = Deno.env.get("PROXY_PASSWORD");
export const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER");
export const TMDB_API_KEY = Deno.env.get("TMDB_API_KEY");
export const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
export const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
export const UNSPLASH_API_KEY = Deno.env.get("UNSPLASH_API_KEY");
export const TELEGRAM_API_KEY = Deno.env.get("TELEGRAM_API_KEY")!;
export const GITEA_SERVER = Deno.env.get("GITEA_SERVER");
export const GITEA_CLIENT_ID = Deno.env.get("GITEA_CLIENT_ID")!;
export const GITEA_CLIENT_SECRET = Deno.env.get("GITEA_CLIENT_SECRET");
export const GITEA_REDIRECT_URL = Deno.env.get("GITEA_REDIRECT_URL");
export const DATABASE_URL = Deno.env.get("DATABASE_URL") || "dev.db";
const duration = Deno.env.get("SESSION_DURATION");
export const SESSION_DURATION = duration ? +duration : (60 * 60 * 24);
export const MARKA_API_KEY = Deno.env.get("MARKA_API_KEY");
export const MARKA_API_URL = Deno.env.get("MARKA_API_URL");
export const JWT_SECRET = Deno.env.get("JWT_SECRET");
export const DATA_DIR = Deno.env.has("DATA_DIR")

View File

@@ -1,8 +1,9 @@
import { FreshContext } from "$fresh/server.ts";
import { Context } from "fresh";
import { State } from "../utils.ts";
class DomainError extends Error {
status = 500;
render?: (ctx: FreshContext) => void;
render?: (ctx: Context<State>) => void;
constructor(public statusText = "Internal Server Error") {
super();
}

View File

@@ -31,19 +31,54 @@ export const fixRenderedMarkdown = (content: string) => {
});
};
export async function fetchStream(url: string, cb: (chunk: string) => void) {
const response = await fetch(url);
const reader = response?.body?.getReader();
if (reader) {
type StreamMessage = {
type: "info";
message: string;
} | {
type: "error";
message: string;
} | {
type: "warning";
message: string;
} | {
type: "finished";
url: string;
};
export async function fetchStream(
url: string,
cb: (chunk: StreamMessage) => void,
init?: RequestInit,
) {
const res = await fetch(url, init);
if (!res.body) return;
let buffer = "";
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(
new TransformStream<string, string>({
transform(chunk, controller) {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (line) controller.enqueue(line);
}
},
flush(controller) {
const line = buffer.trim();
if (line) controller.enqueue(line);
},
}),
)
.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) return;
const data = new TextDecoder().decode(value);
data
.split("$")
.filter((d) => d && d.length)
.map((d) => cb(Array.isArray(d) ? d[0] : d));
}
if (done) break;
cb(JSON.parse(value));
}
}
@@ -58,32 +93,53 @@ export function hashString(message: string) {
}
export const createStreamResponse = () => {
let controller: ReadableStreamController<ArrayBufferView>;
const body = new ReadableStream({
start(cont) {
controller = cont;
const encoder = new TextEncoder();
let controller: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
});
const response = new Response(body, {
headers: {
"content-type": "text/plain",
// newline-delimited JSON
"content-type": "application/x-ndjson; charset=utf-8",
// prevent intermediaries from buffering/transforming
"cache-control": "no-cache, no-transform",
"x-content-type-options": "nosniff",
// nginx hint to disable proxy buffering
"x-accel-buffering": "no",
// if you control compression, keep it off for streams
// "content-encoding": "identity",
},
});
function cancel() {
controller.close();
const send = (obj: unknown) => {
controller.enqueue(encoder.encode(JSON.stringify(obj) + "\n")); // ← delimiter
};
const cancel = () => controller.close();
function info(message: string) {
return send({ type: "info", message });
}
function enqueue(chunk: string) {
controller?.enqueue(new TextEncoder().encode("$" + chunk));
function error(message: string) {
return send({ type: "error", message });
}
function warning(message: string) {
return send({ type: "warning", message });
}
return {
response,
cancel,
enqueue,
send,
info,
error,
warning,
};
};
@@ -102,8 +158,33 @@ export function debounce<T extends (...args: Parameters<T>) => void>(
}
export function parseRating(rating: string | number) {
if (typeof rating === "string") {
return [...rating.matchAll(/⭐/)].length;
if (typeof rating == "number") return rating;
try {
const res = parseInt(rating);
if (!Number.isNaN(res)) return res;
} catch (_e) {
// This is okay
}
return rating;
return rating.length / 2;
}
export async function convertOggToMp3(
oggData: ArrayBuffer,
): Promise<Uint8Array> {
const ffmpeg = new Deno.Command("ffmpeg", {
args: ["-f", "ogg", "-i", "pipe:0", "-f", "mp3", "pipe:1"],
stdin: "piped",
stdout: "piped",
stderr: "null",
});
const process = ffmpeg.spawn();
const writer = process.stdin.getWriter();
await writer.write(new Uint8Array(oggData));
await writer.close();
const output = await process.output();
const { code } = await process.status;
if (code !== 0) throw new Error(`FFmpeg exited with code ${code}`);
return output.stdout;
}

View File

@@ -1,286 +1,93 @@
import { useEffect, useMemo, useRef } from "preact/hooks";
export interface CallOptions {
/**
* Controls if the function should be invoked on the leading edge of the timeout.
*/
leading?: boolean;
/**
* Controls if the function should be invoked on the trailing edge of the timeout.
*/
trailing?: boolean;
}
export interface Options extends CallOptions {
/**
* The maximum time the given function is allowed to be delayed before it's invoked.
*/
maxWait?: number;
}
export interface ControlFunctions {
/**
* Cancel pending function invocations
*/
type Debounced<T extends (...args: unknown[]) => unknown> =
& ((
...args: Parameters<T>
) => void)
& {
cancel: () => void;
/**
* Immediately invoke pending function invocations
*/
flush: () => void;
/**
* Returns `true` if there are any pending function invocations
*/
isPending: () => boolean;
}
pending: () => boolean;
};
/**
* Subsequent calls to the debounced function `debounced.callback` return the result of the last func invocation.
* Note, that if there are no previous invocations it's mean you will get undefined. You should check it in your code properly.
*/
export interface DebouncedState<T extends (...args: any) => ReturnType<T>>
extends ControlFunctions {
(...args: Parameters<T>): ReturnType<T> | undefined;
}
/**
* Creates a debounced function that delays invoking `func` until after `wait`
* milliseconds have elapsed since the last time the debounced function was
* invoked, or until the next browser frame is drawn.
*
* The debounced function comes with a `cancel` method to cancel delayed `func`
* invocations and a `flush` method to immediately invoke them.
*
* Provide `options` to indicate whether `func` should be invoked on the leading
* and/or trailing edge of the `wait` timeout. The `func` is invoked with the
* last arguments provided to the debounced function.
*
* Subsequent calls to the debounced function return the result of the last
* `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the debounced function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* If `wait` is omitted in an environment with `requestAnimationFrame`, `func`
* invocation will be deferred until the next frame is drawn (typically about
* 16ms).
*
* See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
* for details over the differences between `debounce` and `throttle`.
*
* @category Function
* @param {Function} func The function to debounce.
* @param {number} [wait=0]
* The number of milliseconds to delay; if omitted, `requestAnimationFrame` is
* used (if available, otherwise it will be setTimeout(...,0)).
* @param {Object} [options={}] The options object.
* Controls if `func` should be invoked on the leading edge of the timeout.
* @param {boolean} [options.leading=false]
* The maximum time `func` is allowed to be delayed before it's invoked.
* @param {number} [options.maxWait]
* Controls if `func` should be invoked the trailing edge of the timeout.
* @param {boolean} [options.trailing=true]
* @returns {Function} Returns the new debounced function.
* @example
*
* // Avoid costly calculations while the window size is in flux.
* const resizeHandler = useDebouncedCallback(calculateLayout, 150);
* window.addEventListener('resize', resizeHandler)
*
* // Invoke `sendMail` when clicked, debouncing subsequent calls.
* const clickHandler = useDebouncedCallback(sendMail, 300, {
* leading: true,
* trailing: false,
* })
* <button onClick={clickHandler}>click me</button>
*
* // Ensure `batchLog` is invoked once after 1 second of debounced calls.
* const debounced = useDebouncedCallback(batchLog, 250, { 'maxWait': 1000 })
* const source = new EventSource('/stream')
* source.addEventListener('message', debounced)
*
* // Cancel the trailing debounced invocation.
* window.addEventListener('popstate', debounced.cancel)
*
* // Check for pending invocations.
* const status = debounced.pending() ? "Pending..." : "Ready"
*/
export default function useDebouncedCallback<
T extends (...args: any) => ReturnType<T>,
T extends (...args: unknown[]) => unknown,
>(
func: T,
wait?: number,
options?: Options,
): DebouncedState<T> {
const lastCallTime = useRef(null);
const lastInvokeTime = useRef(0);
const timerId = useRef(null);
const lastArgs = useRef<unknown[]>([]);
const lastThis = useRef<unknown>();
const result = useRef<ReturnType<T>>();
const funcRef = useRef(func);
const mounted = useRef(true);
callback: T,
delay: number,
options?: {
/** Call on the leading edge. Default: false */
leading?: boolean;
/** Call on the trailing edge. Default: true */
trailing?: boolean;
},
): Debounced<T> {
const callbackRef = useRef(callback);
const timerRef = useRef<number | null>(null);
const argsRef = useRef<Parameters<T> | null>(null);
// Always use the latest callback without re-creating the debounced fn
useEffect(() => {
funcRef.current = func;
}, [func]);
callbackRef.current = callback;
}, [callback]);
// Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
const useRAF = !wait && wait !== 0 && typeof window !== "undefined";
const leading = !!options?.leading;
const trailing = options?.trailing !== false; // default true
if (typeof func !== "function") {
throw new TypeError("Expected a function");
const debounced = useMemo<Debounced<T>>(() => {
const clear = () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
wait = +wait || 0;
options = options || {};
const leading = !!options.leading;
const trailing = "trailing" in options ? !!options.trailing : true; // `true` by default
const maxing = "maxWait" in options;
const maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : null;
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
// You may have a question, why we have so many code under the useMemo definition.
//
// This was made as we want to escape from useCallback hell and
// not to initialize a number of functions each time useDebouncedCallback is called.
//
// It means that we have less garbage for our GC calls which improves performance.
// Also, it makes this library smaller.
//
// And the last reason, that the code without lots of useCallback with deps is easier to read.
// You have only one place for that.
const debounced = useMemo(() => {
const invokeFunc = (time: number) => {
const args = lastArgs.current;
const thisArg = lastThis.current;
lastArgs.current = lastThis.current = null;
lastInvokeTime.current = time;
return (result.current = funcRef.current.apply(thisArg, args));
};
const startTimer = (pendingFunc: () => void, wait: number) => {
if (useRAF) cancelAnimationFrame(timerId.current);
timerId.current = useRAF
? requestAnimationFrame(pendingFunc)
: setTimeout(pendingFunc, wait);
};
const shouldInvoke = (time: number) => {
if (!mounted.current) return false;
const timeSinceLastCall = time - lastCallTime.current;
const timeSinceLastInvoke = time - lastInvokeTime.current;
// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return (
!lastCallTime.current ||
timeSinceLastCall >= wait ||
timeSinceLastCall < 0 ||
(maxing && timeSinceLastInvoke >= maxWait)
);
};
const trailingEdge = (time: number) => {
timerId.current = null;
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgs.current) {
return invokeFunc(time);
const invoke = () => {
const a = argsRef.current;
argsRef.current = null;
if (a) {
callbackRef.current(...a);
}
lastArgs.current = lastThis.current = null;
return result.current;
};
const timerExpired = () => {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// https://github.com/xnimorz/use-debounce/issues/97
if (!mounted.current) {
return;
}
// Remaining wait calculation
const timeSinceLastCall = time - lastCallTime.current;
const timeSinceLastInvoke = time - lastInvokeTime.current;
const timeWaiting = wait - timeSinceLastCall;
const remainingWait = maxing
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
const fn = ((...args: Parameters<T>) => {
const shouldCallLeading = leading && timerRef.current == null;
// Restart the timer
startTimer(timerExpired, remainingWait);
argsRef.current = args;
if (timerRef.current != null) clearTimeout(timerRef.current);
timerRef.current = globalThis.setTimeout(() => {
timerRef.current = null;
if (trailing) invoke();
}, delay);
if (shouldCallLeading) {
// Leading edge call happens immediately
invoke();
}
}) as Debounced<T>;
fn.cancel = () => {
argsRef.current = null;
clear();
};
const func: DebouncedState<T> = (...args: Parameters<T>): ReturnType<T> => {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs.current = args;
lastThis.current = this;
lastCallTime.current = time;
if (isInvoking) {
if (!timerId.current && mounted.current) {
// Reset any `maxWait` timer.
lastInvokeTime.current = lastCallTime.current;
// Start the timer for the trailing edge.
startTimer(timerExpired, wait);
// Invoke the leading edge.
return leading ? invokeFunc(lastCallTime.current) : result.current;
fn.flush = () => {
if (timerRef.current != null) {
clear();
invoke();
}
if (maxing) {
// Handle invocations in a tight loop.
startTimer(timerExpired, wait);
return invokeFunc(lastCallTime.current);
}
}
if (!timerId.current) {
startTimer(timerExpired, wait);
}
return result.current;
};
func.cancel = () => {
if (timerId.current) {
useRAF
? cancelAnimationFrame(timerId.current)
: clearTimeout(timerId.current);
}
lastInvokeTime.current = 0;
lastArgs.current =
lastCallTime.current =
lastThis.current =
timerId.current =
null;
};
fn.pending = () => timerRef.current != null;
func.isPending = () => {
return !!timerId.current;
};
return fn;
// Recreate only if timing/edge behavior changes
}, [delay, leading, trailing]);
func.flush = () => {
return !timerId.current ? result.current : trailingEdge(Date.now());
};
return func;
}, [leading, maxing, wait, maxWait, trailing, useRAF]);
// Cancel on unmount
useEffect(() => () => debounced.cancel(), [debounced]);
return debounced;
}

View File

@@ -6,7 +6,7 @@ export function useEventListener<T extends Event>(
element: typeof globalThis | HTMLElement = globalThis,
) {
// Create a ref that stores handler
const savedHandler = useRef<(event: Event) => void>();
const savedHandler = useRef<(event: T) => void>();
// Update ref.current value if handler changes.
// This allows our effect below to always get latest handler ...
@@ -27,11 +27,11 @@ export function useEventListener<T extends Event>(
const eventListener = (event: T) => savedHandler?.current?.(event);
// Add event listener
element.addEventListener(eventName, eventListener);
element.addEventListener(eventName, (ev) => eventListener(ev as T));
// Remove event listener on cleanup
return () => {
element.removeEventListener(eventName, eventListener);
element.removeEventListener(eventName, (ev) => eventListener(ev as T));
};
},
[eventName, element], // Re-run if eventName or element changes

View File

@@ -6,7 +6,7 @@ type ThrottleOptions = {
};
const useThrottledCallback = (
callback: (...args: any[]) => void,
callback: (...args: unknown[]) => void,
delay: number,
options: ThrottleOptions = {},
) => {
@@ -24,7 +24,7 @@ const useThrottledCallback = (
};
}, [timer]);
const throttledCallback = (...args: any[]) => {
const throttledCallback = (...args: unknown[]) => {
const now = Date.now();
if (leading && !isLeading) {
@@ -52,4 +52,3 @@ const useThrottledCallback = (
};
export default useThrottledCallback;

View File

@@ -1,14 +1,17 @@
import { useEffect, useRef } from "preact/hooks";
export function useTraceUpdate(props) {
export function useTraceUpdate(props: Record<string, unknown>) {
const prev = useRef(props);
useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
const changedProps = Object.entries(props).reduce(
(ps: Record<string, unknown>, [k, v]) => {
if (prev.current[k] !== v) {
ps[k] = [prev.current[k], v];
}
return ps;
}, {});
},
{},
);
if (Object.keys(changedProps).length > 0) {
console.log("Changed props:", changedProps);
}

View File

@@ -1,19 +1,18 @@
import { rgbToHex } from "@lib/string.ts";
import { createLogger } from "@lib/log/index.ts";
import { generateThumbhash } from "@lib/thumbhash.ts";
import { parseMediaType } from "https://deno.land/std@0.224.0/media_types/parse_media_type.ts";
import { parseMediaType } from "@std/media-types";
import path from "node:path";
import { ensureDir } from "https://deno.land/std@0.216.0/fs/mod.ts";
import { mkdir } from "node:fs/promises";
import { DATA_DIR } from "@lib/env.ts";
import { db } from "@lib/db/sqlite.ts";
import { imageTable } from "@lib/db/schema.ts";
import { eq } from "drizzle-orm";
import sharp from "npm:sharp@next";
const log = createLogger("cache/image");
const imageDir = path.join(DATA_DIR, "images");
await ensureDir(imageDir);
await mkdir(imageDir, { recursive: true });
async function getRemoteImage(imageUrl: string) {
try {
@@ -100,7 +99,7 @@ async function getLocalImagePath(
hostname,
pathname.split("/").filter((s) => s.length).join("-"),
);
await ensureDir(imagePath);
await mkdir(imagePath, { recursive: true });
if (width || height) {
imagePath = path.join(imagePath, `${width ?? "-"}x${height ?? "-"}`);
@@ -134,7 +133,7 @@ async function getLocalImage(
*/
async function storeLocalImage(
url: string,
content: ArrayBuffer,
content: Uint8Array<ArrayBuffer> | ArrayBuffer,
{ width, height }: { width?: number; height?: number } = {},
) {
const isValid = await verifyImage(new Uint8Array(content));
@@ -158,6 +157,8 @@ async function resizeImage(
mediaType: string;
},
) {
const sharp = (await import("sharp")).default;
try {
log.debug("Resizing image", { params });
@@ -211,6 +212,8 @@ async function resizeImage(
async function createThumbhash(
image: Uint8Array,
): Promise<{ hash: string; average: string }> {
const sharp = (await import("sharp")).default;
try {
const resizedImage = await sharp(image)
.resize(100, 100, { fit: "cover" }) // Keep aspect ratio within bounds
@@ -219,7 +222,11 @@ async function createThumbhash(
.raw()
.toBuffer();
const [hash, average] = generateThumbhash(resizedImage, 100, 100);
const [hash, average] = generateThumbhash(
new Uint8Array(resizedImage),
100,
100,
);
return {
hash: btoa(String.fromCharCode(...hash)),
@@ -234,6 +241,8 @@ async function createThumbhash(
* Verifies that an image buffer contains valid image data
*/
async function verifyImage(imageBuffer: Uint8Array): Promise<boolean> {
const sharp = (await import("sharp")).default;
try {
const metadata = await sharp(imageBuffer).metadata();
return !!(metadata.width && metadata.height && metadata.format);
@@ -249,7 +258,7 @@ async function verifyImage(imageBuffer: Uint8Array): Promise<boolean> {
export async function getImageContent(
url: string,
{ width, height }: { width?: number; height?: number } = {},
): Promise<{ content: ArrayBuffer; mimeType: string }> {
): Promise<{ content: Uint8Array<ArrayBuffer>; mimeType: string }> {
log.debug("Getting image content", { url, width, height });
// Check if we have the image metadata in database
@@ -267,8 +276,8 @@ export async function getImageContent(
// Fetch and cache original if needed
if (!originalImage) {
const fetchedImage = await getRemoteImage(url);
await storeLocalImage(url, fetchedImage.buffer);
originalImage = new Uint8Array(fetchedImage.buffer);
await storeLocalImage(url, originalImage);
}
// Resize image
@@ -313,7 +322,7 @@ export async function getImage(url: string) {
// Store in database
const [newImage] = await db.insert(imageTable).values({
url: url,
blurhash: thumbhash.hash,
thumbhash: thumbhash.hash,
average: thumbhash.average,
mime: imageContent.mediaType,
}).returning();

3
lib/kmenu.ts Normal file
View File

@@ -0,0 +1,3 @@
import { signal } from "@preact/signals";
export const isKMenuOpen = signal(false);

View File

@@ -1,12 +1,12 @@
import * as env from "@lib/env.ts";
import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts";
import { join } from "node:path";
import { getLogLevel, LOG_LEVEL } from "@lib/log/types.ts";
import { mkdir } from "node:fs/promises";
export const LOG_DIR = join(env.DATA_DIR, "logs");
// Ensure the log directory exists
await ensureDir(LOG_DIR);
await mkdir(LOG_DIR, { recursive: true });
export let logLevel = getLogLevel(env.LOG_LEVEL);
export function setLogLevel(level: LOG_LEVEL) {

View File

@@ -36,12 +36,12 @@ export async function getLogs() {
.map((line) => {
const [date, ...rest] = line.split(" | ");
const parsed = JSON.parse(rest.join(" | ")) as Log;
const dateObj = new Date(date);
return {
...parsed,
date: new Date(date),
date: isNaN(dateObj.getTime()) ? new Date() : dateObj,
} as Log;
});
console.log(logs);
// Return the logs sorted by date
return logs.sort((a, b) => a.date.getTime() - b.date.getTime());

View File

@@ -38,13 +38,13 @@ export function createLogger(scope: string, _options?: LoggerOptions): Logger {
export function loggerFromStream(stream: StreamResponse) {
return {
debug: (...data: unknown[]) =>
stream.enqueue(`${data.length > 1 ? data.join(" ") : data[0]}`),
stream.info(`${data.length > 1 ? data.join(" ") : data[0]}`),
info: (...data: unknown[]) =>
stream.enqueue(`${data.length > 1 ? data.join(" ") : data[0]}`),
stream.info(`${data.length > 1 ? data.join(" ") : data[0]}`),
error: (...data: unknown[]) =>
stream.enqueue(`[ERROR]: ${data.length > 1 ? data.join(" ") : data[0]}`),
stream.error(`[ERROR]: ${data.length > 1 ? data.join(" ") : data[0]}`),
warn: (...data: unknown[]) =>
stream.enqueue(`[WARN]: ${data.length > 1 ? data.join(" ") : data[0]}`),
stream.warning(`[WARN]: ${data.length > 1 ? data.join(" ") : data[0]}`),
};
}

117
lib/marka/index.ts Normal file
View File

@@ -0,0 +1,117 @@
import { createCache } from "../cache.ts";
import { MARKA_API_KEY, MARKA_API_URL } from "../env.ts";
import { getImage } from "../image.ts";
import { GenericResource } from "./schema.ts";
async function addImageToResource<T extends GenericResource>(
resource: GenericResource,
): Promise<T> {
const imageUrl = resource?.content?.image;
if (imageUrl) {
try {
const absoluteImageUrl = (imageUrl.startsWith("https://") ||
imageUrl.startsWith("http://"))
? imageUrl
: `${MARKA_API_URL}/${imageUrl}`;
const image = await getImage(absoluteImageUrl);
return { ...resource, image } as T;
} catch (e) {
console.log(`Failed to fetch image: ${imageUrl}`, e);
}
}
return resource as T;
}
type Resource = GenericResource & {
content: GenericResource["content"] & Array<GenericResource>;
};
const fetchCache = createCache<Resource>("marka");
const cacheLock = new Map<string, Promise<Resource>>();
async function fetchAndStoreUrl(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch resource: ${response.status}`);
}
const res = await response.json();
fetchCache.set(url, res);
return res;
}
async function cachedFetch(
url: string,
): Promise<Resource | undefined> {
if (fetchCache.has(url)) {
fetchAndStoreUrl(url); // Fetch the url in the background
return fetchCache.get(url);
}
if (cacheLock.has(url)) return cacheLock.get(url);
const response = fetchAndStoreUrl(url);
cacheLock.set(url, response);
const res = await response;
cacheLock.delete(url);
return res;
}
export async function fetchResource<T extends GenericResource>(
resource: string,
): Promise<T | undefined> {
try {
const d = `${MARKA_API_URL}/resources/${resource}`;
const res = await cachedFetch(d);
if (!res) return;
return addImageToResource<T>(res);
} catch (_e) {
return;
}
}
export async function listResources<T extends GenericResource>(
resource: string,
): Promise<T[]> {
try {
const d = `${MARKA_API_URL}/resources/${resource}`;
const list = await cachedFetch(d);
if (!list) return [];
return Promise.all(
list?.content
.filter((a) => a?.content?._type)
.map((res) => addImageToResource(res) as Promise<T>),
);
} catch (_e) {
console.log(`Failed to fetch resource: ${resource}`, _e);
return [];
}
}
export async function createResource(
path: string,
content: string | object | ArrayBuffer,
) {
const isJson = typeof content === "object" &&
!(content instanceof ArrayBuffer);
const fetchUrl = `${MARKA_API_URL}/resources/${path}`;
const headers = new Headers();
headers.append("Content-Type", isJson ? "application/json" : "");
if (MARKA_API_KEY) {
headers.append("Authentication", MARKA_API_KEY);
}
const response = await fetch(fetchUrl, {
method: "POST",
headers,
body: isJson ? JSON.stringify(content) : content,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`failed to create resource (resources/${path}): ${
text || response.status
}`,
);
}
return response.json();
}

140
lib/marka/schema.ts Normal file
View File

@@ -0,0 +1,140 @@
import { z } from "zod";
import { imageTable } from "../db/schema.ts";
export const PersonSchema = z.object({
_type: z.literal("Person"),
name: z.string().optional(),
});
export const ReviewRatingSchema = z.object({
bestRating: z.number().optional(),
worstRating: z.number().optional(),
// Accept number or string (e.g., "⭐️⭐️⭐️⭐️⭐️")
ratingValue: z.union([z.number(), z.string()]).optional(),
});
const WithAuthor = z.object({ author: PersonSchema.optional() });
const WithKeywords = z.object({ keywords: z.array(z.string()).optional() });
const WithImage = z.object({ image: z.string().optional() });
const WithDatePublished = z.object({ datePublished: z.string().optional() });
const BaseContent = WithAuthor.merge(WithKeywords)
.merge(WithImage)
.merge(WithDatePublished);
export const BaseFileSchema = z.object({
type: z.literal("file"),
name: z.string(),
path: z.string(),
modTime: z.string(), // ISO timestamp string
mime: z.string(),
size: z.number().int().nonnegative(),
});
const makeContentSchema = <
TName extends "Article" | "Review" | "Recipe",
TShape extends z.ZodRawShape,
>(
name: TName,
shape: TShape,
) =>
z
.object({
_type: z.literal(name),
keywords: z.array(z.string()).optional(),
})
.merge(BaseContent)
.extend(shape);
export const ArticleContentSchema = makeContentSchema("Article", {
headline: z.string().optional(),
articleBody: z.string().optional(),
url: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
});
export const ReviewContentSchema = makeContentSchema("Review", {
tmdbId: z.number().optional(),
link: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
reviewBody: z.string().optional(),
itemReviewed: z
.object({
name: z.string().optional(),
})
.optional(),
});
export const RecipeContentSchema = makeContentSchema("Recipe", {
description: z.string().optional(),
name: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
recipeIngredient: z.array(z.string()).optional(),
recipeInstructions: z.array(z.string()).optional(),
totalTime: z.string().optional(),
recipeYield: z.number().optional(),
url: z.string().optional(),
});
export const articleMetadataSchema = z.object({
headline: z.union([z.null(), z.string()]).describe("Headline of the article"),
author: z.union([z.null(), z.string()]).describe("Author of the article"),
datePublished: z.union([z.null(), z.string()]).describe(
"Date the article was published",
),
keywords: z.union([z.null(), z.array(z.string())]).describe(
"Keywords for the article",
),
});
export const ArticleSchema = BaseFileSchema.extend({
content: ArticleContentSchema,
});
export const ReviewSchema = BaseFileSchema.extend({
content: ReviewContentSchema,
});
export const RecipeSchema = BaseFileSchema.extend({
content: RecipeContentSchema,
});
export const GenericResourceSchema = z.union([
ArticleSchema,
ReviewSchema,
RecipeSchema,
]);
export type Person = z.infer<typeof PersonSchema>;
export type ReviewRating = z.infer<typeof ReviewRatingSchema>;
export type BaseFile = z.infer<typeof BaseFileSchema>;
export type ArticleResource = z.infer<typeof ArticleSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type ReviewResource = z.infer<typeof ReviewSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type RecipeResource = z.infer<typeof RecipeSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type GenericResource = z.infer<typeof GenericResourceSchema> & {
image?: typeof imageTable.$inferSelect;
};
export function getNameOfResource(res: GenericResource): string {
if (res.content?._type === "Article" && res.content.headline) {
return res.content.headline;
}
if (res.content?._type === "Review" && res.content.itemReviewed?.name) {
return res.content.itemReviewed.name;
}
if (res.content?._type === "Recipe" && res.content.name) {
return res.content.name;
}
return "Unnamed Resource";
}

46
lib/markdown.ts Normal file
View File

@@ -0,0 +1,46 @@
import { render } from "gfm";
import "prismjs/components/prism-typescript.js";
import "prismjs/components/prism-bash.js";
import "prismjs/components/prism-rust.js";
export type Document = {
name: string;
lastModified: number;
contentType: string;
content: string | null;
size: number;
perm: string;
};
function removeFrontmatter(doc: string) {
if (doc.trim().startsWith("---")) {
return doc.trim().split("---").filter((s) => s.length).slice(1).join("---");
}
return doc;
}
export function removeImage(doc: string, imageUrl?: string) {
if (!imageUrl) {
return doc;
}
// Remove image from content
const first = doc.slice(0, 500);
const second = doc.slice(500);
// Regex pattern to match the image Markdown syntax with the specific URL
const pattern = new RegExp(
`!\\[.*?\\]\\(${imageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)`,
"g",
);
// Remove the matched image
const updatedMarkdown = first.replace(pattern, "");
return updatedMarkdown + second;
}
export function renderMarkdown(doc: string) {
return render(removeFrontmatter(doc), {
baseUrl: "https://max-richter.dev",
allowMath: true,
});
}

View File

@@ -1,13 +1,14 @@
import OpenAI from "https://deno.land/x/openai@v4.69.0/mod.ts";
import { zodResponseFormat } from "https://deno.land/x/openai@v4.69.0/helpers/zod.ts";
import OpenAI, { toFile } from "@openai/openai";
import { zodResponseFormat } from "@openai/openai/helpers/zod";
import { OPENAI_API_KEY } from "@lib/env.ts";
import { hashString } from "@lib/helpers.ts";
import { createCache } from "@lib/cache.ts";
import { recipeResponseSchema } from "@lib/recipeSchema.ts";
import { articleMetadataSchema } from "./marka/schema.ts";
const openAI = OPENAI_API_KEY && new OpenAI({ apiKey: OPENAI_API_KEY });
interface MovieRecommendation {
export interface MovieRecommendation {
year: number;
title: string;
}
@@ -24,10 +25,12 @@ function extractListFromResponse(response?: string): string[] {
.filter((line) => line.length > 2);
}
const model = "gpt-4.1-mini";
export async function summarize(content: string) {
if (!openAI) return;
const chatCompletion = await openAI.chat.completions.create({
model: "gpt-3.5-turbo",
model: model,
messages: [
{
role: "user",
@@ -44,7 +47,7 @@ export async function summarize(content: string) {
export async function shortenTitle(content: string) {
if (!openAI) return;
const chatCompletion = await openAI.chat.completions.create({
model: "gpt-3.5-turbo",
model: model,
messages: [
{
role: "system",
@@ -64,7 +67,7 @@ export async function shortenTitle(content: string) {
export async function extractAuthorName(content: string) {
if (!openAI) return;
const chatCompletion = await openAI.chat.completions.create({
model: "gpt-3.5-turbo",
model: model,
messages: [
{
role: "system",
@@ -95,7 +98,7 @@ export async function createGenres(
) {
if (!openAI) return;
const chatCompletion = await openAI.chat.completions.create({
model: "gpt-3.5-turbo",
model: model,
messages: [
{
role: "system",
@@ -124,7 +127,7 @@ export async function createKeywords(
) {
if (!openAI) return;
const chatCompletion = await openAI.chat.completions.create({
model: "gpt-3.5-turbo",
model: model,
messages: [
{
"role": "system",
@@ -156,7 +159,7 @@ export const getMovieRecommendations = async (
if (cache.has(cacheId)) return cache.get(cacheId);
const chatCompletion = await openAI.chat.completions.create({
model: "gpt-3.5-turbo",
model: model,
messages: [
{
role: "user",
@@ -178,24 +181,41 @@ respond with a plain unordered list each item starting with the year the movie w
if (!res) return;
const recommendations = res.split("\n").map((entry) => {
const recommendations = res.split("\n").map((entry: string) => {
const [year, ...title] = entry.split("-");
return {
year: parseInt(year.trim()),
title: title.join(" ").replaceAll('"', "").trim(),
};
}).filter((y) => !Number.isNaN(y.year));
}).filter((y: { year: number }) => !Number.isNaN(y.year));
cache.set(cacheId, recommendations);
return recommendations;
};
export async function createUnsplashSearchTerm(content: string) {
if (!openAI) return;
const chatCompletion = await openAI.chat.completions.create({
model: model,
messages: [
{
role: "system",
content:
"Please respond with a search term for unsplash for the following article",
},
{ role: "user", content: content.slice(0, 10_000) },
],
});
return chatCompletion.choices[0].message.content?.toLowerCase();
}
export async function createTags(content: string) {
if (!openAI) return;
const chatCompletion = await openAI.chat.completions.create({
model: "gpt-3.5-turbo",
model: model,
messages: [
{
role: "system",
@@ -213,8 +233,8 @@ export async function createTags(content: string) {
export async function extractRecipe(content: string) {
if (!openAI) return;
const completion = await openAI.beta.chat.completions.parse({
model: "gpt-4o-2024-08-06",
const completion = await openAI.chat.completions.parse({
model: model,
temperature: 0.1,
messages: [
{
@@ -228,3 +248,43 @@ export async function extractRecipe(content: string) {
return recipeResponseSchema.parse(completion.choices[0].message.parsed);
}
export async function extractArticleMetadata(content: string) {
if (!openAI) return;
const completion = await openAI.chat.completions.parse({
model: model,
temperature: 0.1,
messages: [
{
role: "system",
content:
"Extract the article information from the provided markdown. If the specified data is not available return undefined for the data values.",
},
{ role: "user", content },
],
response_format: zodResponseFormat(
articleMetadataSchema,
"article-meta-v2",
),
});
return articleMetadataSchema.parse(completion.choices[0].message.parsed);
}
export async function transcribe(
mp3Data: Uint8Array,
): Promise<string | undefined> {
if (!openAI) return;
const file = await toFile(mp3Data, "audio.mp3", {
type: "audio/mpeg",
});
const result = await openAI.audio.transcriptions.create({
file,
model: "whisper-1",
response_format: "text",
});
return result;
}

View File

@@ -1,7 +1,7 @@
import {
parseIngredient,
unitsOfMeasure as _unitsOfMeasure,
} from "https://esm.sh/parse-ingredient@1.2.1";
} from "parse-ingredient";
import { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
import { removeMarkdownFormatting } from "@lib/string.ts";
@@ -77,7 +77,7 @@ export function parseIngredients(
};
const unit = ingredient.unit.toLowerCase() as keyof typeof unitsOfMeasure;
if (unit in unitsOfMeasure && unit !== "cup") {
if (unit in unitsOfMeasure) {
ingredient.unit = unitsOfMeasure[unit].short;
}

View File

@@ -1,22 +1,15 @@
import { firefox } from "npm:playwright-extra";
import { firefox } from "playwright-extra";
import { createStreamResponse } from "@lib/helpers.ts";
import StealthPlugin from "npm:puppeteer-extra-plugin-stealth";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
import * as env from "@lib/env.ts";
const userAgentStrings = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.2227.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.3497.92 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36",
];
firefox.use(StealthPlugin());
export async function fetchHtmlWithPlaywright(
fetchUrl: string,
streamResponse: ReturnType<typeof createStreamResponse>,
): Promise<string> {
streamResponse.enqueue("booting up playwright");
streamResponse.info("booting up playwright");
const config: Parameters<typeof firefox.launch>[0] = {};
if (env.PROXY_SERVER) {
@@ -31,7 +24,7 @@ export async function fetchHtmlWithPlaywright(
// Launch the Playwright browser
const browser = await firefox.launch(config);
streamResponse.enqueue("fetching html");
streamResponse.info("fetching html");
try {
// Open a new browser context and page
@@ -49,7 +42,7 @@ export async function fetchHtmlWithPlaywright(
return html;
} catch (error) {
streamResponse.enqueue("error fetching html");
streamResponse.error("error fetching html");
console.error(error);
return "";
} finally {

View File

@@ -1,105 +0,0 @@
/**
* Interface zur Beschreibung eines eingereihten Promises in der `PromiseQueue`.
*/
interface QueuedPromise<T = any> {
promise: () => Promise<T>;
resolve: (value: T) => void;
reject: (reason?: any) => void;
}
/**
* Eine einfache Promise Queue, die es ermöglicht mehrere Aufgaben in kontrollierter
* Reihenfolge abzuarbeiten.
*
* Lizenz: CC BY-NC-SA 4.0
* (c) Peter Müller <peter@crycode.de> (https://crycode.de/promise-queue-in-typescript)
*/
export class PromiseQueue {
/**
* Eingereihte Promises.
*/
private queue: QueuedPromise[] = [];
/**
* Indikator, dass aktuell ein Promise abgearbeitet wird.
*/
private working = false;
/**
* Ein Promise einreihen.
* Dies fügt das Promise der Warteschlange hinzu. Wenn die Warteschlange leer
* ist, dann wird das Promise sofort gestartet.
* @param promise Funktion, die das Promise zurückgibt.
* @returns Ein Promise, welches eingelöst (oder zurückgewiesen) wird sobald das eingereihte Promise abgearbeitet ist.
*/
public enqueue<T = void>(promise: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({
promise,
resolve,
reject,
});
this.dequeue();
});
}
/**
* Das erste Promise aus der Warteschlange holen und starten, sofern nicht
* bereits ein Promise aktiv ist.
* @returns `true` wenn ein Promise aus der Warteschlange gestartet wurde oder `false` wenn bereits ein Promise aktiv oder die Warteschlange leer ist.
*/
private dequeue(): boolean {
if (this.working) {
return false;
}
const item = this.queue.shift();
if (!item) {
return false;
}
try {
this.working = true;
item.promise()
.then((value) => {
item.resolve(value);
})
.catch((err) => {
item.reject(err);
})
.finally(() => {
this.working = false;
this.dequeue();
});
} catch (err) {
item.reject(err);
this.working = false;
this.dequeue();
}
return true;
}
}
export class ConcurrentPromiseQueue {
/**
* Eingereihte Promises.
*/
private queues: PromiseQueue[] = [];
constructor(concurrency: number = 1) {
this.queues = Array.from({ length: concurrency }).map(() => {
return new PromiseQueue();
});
}
private queueIndex = 0;
private getQueue() {
this.queueIndex = (this.queueIndex + 1) % this.queues.length;
return this.queues[this.queueIndex];
}
public enqueue<T = void>(promise: () => Promise<T>): Promise<T> {
return this.getQueue().enqueue(promise);
}
}

View File

@@ -1,4 +1,5 @@
import { z } from "npm:zod";
import { z } from "zod";
import { RecipeResource } from "./marka/schema.ts";
export const IngredientSchema = z.object({
quantity: z.string().describe(
@@ -17,40 +18,43 @@ export const IngredientGroupSchema = z.object({
export type IngredientGroup = z.infer<typeof IngredientGroupSchema>;
const recipeSchema = z.object({
title: z.string().describe(
"Title of the Recipe, without the name of the website or author",
_type: z.literal("Recipe"),
name: z.string().describe(
"Name of the Recipe, without the name of the website or author",
),
description: z.string().describe(
"Optional, short description of the recipe",
),
image: z.string().describe("URL of the main image of the recipe"),
author: z.string().describe("author of the Recipe (optional)"),
description: z.string().describe("Optional, short description of the recipe"),
ingredients: z.array(z.union([IngredientSchema, IngredientGroupSchema]))
.describe("List of ingredients"),
instructions: z.array(z.string()).describe("List of instructions"),
servings: z.number().describe("Amount of Portions"),
prepTime: z.number().describe("Preparation time in minutes"),
cookTime: z.number().describe("Cooking time in minutes"),
totalTime: z.number().describe("Total time in minutes"),
tags: z.array(z.string()).describe(
"List of tags (e.g., ['vegan', 'dessert'])",
author: z.object({
_type: z.literal("Person"),
name: z.string().describe("author of the Recipe (optional)"),
}),
keywords: z.array(z.string()).describe(
"List of keywords that match the recipe",
),
notes: z.array(z.string()).describe("Optional notes about the recipe"),
recipeIngredient: z.array(z.string())
.describe("List of ingredients"),
recipeInstructions: z.array(z.string()).describe("List of instructions"),
recipeYield: z.number().describe("Amount of Portions"),
totalTime: z.number().describe("Preparation time in minutes"),
});
const noRecipeSchema = z.object({
errorMessages: z.array(z.string()).describe(
"List of error messages, if no recipe was found",
),
});
export type Recipe = z.infer<typeof recipeSchema>;
const noRecipeSchema = z.literal("none").describe("No Recipe found");
export const recipeResponseSchema = z.union([recipeSchema, noRecipeSchema]);
export function isValidRecipe(
recipe:
| { ingredients?: unknown[]; instructions?: string[]; name?: string }
| RecipeResource
| null
| undefined,
) {
return recipe?.ingredients?.length && recipe?.instructions?.length &&
return recipe?.content?.recipeIngredient?.length &&
recipe?.content?.recipeIngredient.length > 1 &&
recipe?.content?.recipeInstructions?.length &&
recipe.name?.length;
}

View File

@@ -1,10 +1,11 @@
import * as openai from "@lib/openai.ts";
import { type MovieRecommendation } from "@lib/openai.ts";
import * as tmdb from "@lib/tmdb.ts";
import { GenericResource } from "@lib/types.ts";
import { parseRating } from "@lib/helpers.ts";
import { createCache } from "@lib/cache.ts";
import { ReviewResource } from "./marka/schema.ts";
type RecommendationResource = {
export type RecommendationResource = {
id: string;
type: string;
rating: number;
@@ -18,44 +19,52 @@ type RecommendationResource = {
const cache = createCache<RecommendationResource>("recommendations");
export async function createRecommendationResource(
res: GenericResource,
res: ReviewResource,
description?: string,
) {
const cacheId = `${res.type}:${res.id.replaceAll(":", "")}`;
const cacheId = `${res.type}:${res.name.replaceAll(":", "")}`;
const resource = cache.get(cacheId) || {
id: res.id,
id: res.name,
type: res.type,
rating: -1,
};
if (description && !resource.keywords) {
const keywords = await openai.createKeywords(res.type, description, res.id);
const keywords = await openai.createKeywords(
res.type,
description,
res.name,
);
if (keywords?.length) {
resource.keywords = keywords;
}
}
const { author, date, rating } = res.meta || {};
const { author, datePublished, reviewRating } = res.content;
if (res?.tags) {
resource.tags = res.tags;
if (res?.content?.keywords) {
resource.keywords = res.content.keywords;
}
if (typeof rating !== "undefined") {
resource.rating = parseRating(rating);
if (typeof reviewRating?.ratingValue !== "undefined") {
resource.rating = parseRating(reviewRating?.ratingValue);
}
if (author) {
resource.author = author;
if (author?.name) {
resource.author = author.name;
}
if (description) {
resource.description = description;
}
if (date) {
const d = typeof date === "string" ? new Date(date) : date;
if (datePublished) {
const d = typeof datePublished === "string"
? new Date(datePublished)
: datePublished;
if (!isNaN(d.getTime())) {
resource.year = d.getFullYear();
}
}
cache.set(cacheId, JSON.stringify(resource));
}
@@ -68,7 +77,7 @@ export function getRecommendation(
}
export async function getSimilarMovies(id: string) {
const recs = getRecommendation(id, "movie");
const recs = getRecommendation(id, "movies");
if (!recs?.keywords?.length) return;
const recommendations = await openai.getMovieRecommendations(
@@ -77,10 +86,12 @@ export async function getSimilarMovies(id: string) {
);
if (!recommendations) return;
const movies = await Promise.all(recommendations.map(async (rec) => {
const movies = await Promise.all(
recommendations.map(async (rec: MovieRecommendation) => {
const m = await tmdb.searchMovie(rec.title, rec.year);
return m?.results?.[0];
}));
}),
);
return movies.filter(Boolean);
}
@@ -90,5 +101,7 @@ export async function getAllRecommendations(): Promise<
> {
const keys = cache.keys();
const res = await Promise.all(keys.map((k) => cache.get(k)));
return res.map((r) => JSON.parse(r));
return res.filter((s) => !!s).map((r) =>
typeof r === "string" ? JSON.parse(r) : r
);
}

View File

@@ -1,108 +0,0 @@
import { parseDocument } from "@lib/documents.ts";
import { parse, stringify } from "@std/yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
export type Article = {
id: string;
type: "article";
content: string;
name: string;
tags: string[];
meta: {
done?: boolean;
date: Date;
link: string;
thumbnail?: string;
average?: string;
image?: string;
author?: string;
rating?: number;
};
};
function renderArticle(article: Article) {
const meta = article.meta;
if ("date" in meta) {
meta.date = formatDate(meta.date);
}
return fixRenderedMarkdown(`${meta
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${article.name}
${article.tags.map((t) => `#${t}`).join(" ")}
${article.content}
`);
}
function parseArticle(original: string, id: string): Article {
const doc = parseDocument(original);
let meta = {} as Article["meta"];
let name = "";
const range = [Infinity, -Infinity];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = parse(child.value) as Article["meta"];
} catch (err) {
console.log("Error parsing YAML", err);
console.log("YAML:", child.value);
}
if (meta["rating"] && typeof meta["rating"] === "string") {
meta.rating = [...meta.rating?.matchAll("⭐")].length;
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (name) {
const start = child.position?.start.offset || Infinity;
const end = child.position?.end.offset || -Infinity;
if (start < range[0]) range[0] = start;
if (end > range[1]) range[1] = end;
}
}
let content = original.slice(range[0], range[1]);
const tags = extractHashTags(content);
for (const tag of tags) {
content = content.replace("#" + tag, "");
}
return {
type: "article",
id,
name,
tags,
content,
meta,
};
}
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
render: renderArticle,
hasThumbnails: true,
});
export const getAllArticles = crud.readAll;
export const getArticle = crud.read;
export const createArticle = crud.create;

View File

@@ -1,123 +0,0 @@
import { parseDocument } from "@lib/documents.ts";
import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
export type Movie = {
id: string;
name: string;
description: string;
type: "movie";
tags: string[];
meta: {
date: Date;
tmdbId?: number;
keywords?: string[];
image: string;
thumbnail?: string;
average?: string;
author: string;
rating: number;
};
};
export function renderMovie(movie: Movie) {
const meta = movie.meta;
if ("date" in meta && typeof meta.date !== "string") {
meta.date = formatDate(meta.date) as unknown as Date;
}
delete meta.thumbnail;
delete meta.average;
const movieImage = `![](${movie.meta.image})`;
return fixRenderedMarkdown(`${
meta
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${movie.name}
${
// So we do not add a new image to the description everytime we render
(movie.meta.image && !movie.description.includes(movieImage))
? movieImage
: ""}
${movie.tags.map((t) => `#${t}`).join(" ")}
${movie.description}
`);
}
export function parseMovie(original: string, id: string): Movie {
const doc = parseDocument(original);
let meta = {} as Movie["meta"];
let name = "";
const range = [Infinity, -Infinity];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = (parse(child.value) || {}) as Movie["meta"];
} catch (_) {
// ignore here
}
if (meta["rating"] && typeof meta["rating"] === "string") {
meta.rating = [...meta.rating?.matchAll("⭐")].length;
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (name) {
const start = child.position?.start.offset || Infinity;
const end = child.position?.end.offset || -Infinity;
if (start < range[0]) range[0] = start;
if (end > range[1]) range[1] = end;
}
}
let description = original.slice(range[0], range[1]);
const tags = extractHashTags(description);
for (const tag of tags) {
description = description.replace("#" + tag, "");
}
return {
type: "movie",
id,
name,
tags,
description,
meta,
};
}
const crud = createCrud<Movie>({
prefix: "Media/movies/",
parse: parseMovie,
render: renderMovie,
hasThumbnails: true,
});
export const getMovie = async (id: string) => {
const movie = await crud.read(id);
return movie;
};
export const getAllMovies = crud.readAll;
export const createMovie = crud.create;

View File

@@ -1,209 +0,0 @@
import {
type DocumentChild,
getTextOfRange,
parseDocument,
} from "@lib/documents.ts";
import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags } from "@lib/string.ts";
import { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
import { parseIngredients } from "@lib/parseIngredient.ts";
export type Recipe = {
type: "recipe";
id: string;
name: string;
description?: string;
markdown?: string;
ingredients: (Ingredient | IngredientGroup)[];
instructions?: string[];
notes?: string[];
tags: string[];
meta?: {
time?: string;
link?: string;
image?: string;
rating?: number;
portion?: number;
author?: string;
average?: string;
thumbnail?: string;
};
};
function extractSteps(
content: string,
seperator: RegExp = /\n(?=\d+\.)/g,
): string[] {
const steps = content.split(seperator).map((step) => {
const match = step.match(/^(\d+)\.\s*(.*)/);
if (match) return match[2];
return step;
}).filter((step) => !!step);
return steps as string[];
}
export function parseRecipe(original: string, id: string): Recipe {
const doc = parseDocument(original);
let name = "";
let meta: Recipe["meta"] = {};
const groups: DocumentChild[][] = [];
let group: DocumentChild[] = [];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = parse(child.value) as Recipe["meta"];
} catch (err) {
console.log("Error parsing YAML", err);
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (child.type === "thematicBreak") {
groups.push(group);
group = [];
continue;
}
group.push(child);
}
if (group.length) {
groups.push(group);
}
let description = getTextOfRange(groups[0], original);
let ingredientsText = getTextOfRange(groups[1], original);
if (ingredientsText) {
ingredientsText = ingredientsText.replace(/#+\s?Ingredients?/, "");
} else {
ingredientsText = "";
}
const ingredients = parseIngredients(ingredientsText);
const instructionText = getTextOfRange(groups[2], original);
let instructions = extractSteps(instructionText || "");
if (instructions.length <= 1) {
const d = extractSteps(instructionText || "", /\n/g);
if (d.length > instructions.length) {
instructions = d;
}
}
const tags = extractHashTags(description || "");
if (description) {
for (const tag of tags) {
description = description.replace("#" + tag, "");
}
}
return {
type: "recipe",
id,
meta,
name,
tags,
markdown: original,
notes: getTextOfRange(groups[3], original)?.split("\n"),
description,
ingredients,
instructions,
};
}
function filterUndefinedFromObject<T extends { [key: string]: unknown }>(
obj: T,
) {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v !== undefined),
);
}
export function renderRecipe(recipe: Recipe) {
const meta = filterUndefinedFromObject(recipe.meta || {});
// Clean up meta properties
delete meta.thumbnail;
delete meta.average;
const recipeImage = meta.image ? `![](${meta.image})` : "";
// Format ingredient groups and standalone ingredients
const ingredients = recipe.ingredients
.map((item) => {
if ("items" in item) {
return `\n*${item.name}*\n${
item.items
.map((ing) => {
if (ing.quantity && ing.unit) {
return `- **${ing.quantity.trim() || ""}${
ing.unit.trim() || ""
}** ${ing.name}`;
}
return `- ${ing.name}`;
})
.join("\n")
}`;
}
if (item.quantity && item.unit) {
return `- **${item.quantity?.trim() || ""}${
item.unit?.trim() || ""
}** ${item.name}`;
}
if (item.quantity) {
return `- **${item.quantity}** ${item.name}`;
}
return `- ${item.name}`;
})
.join("\n");
// Format instructions as a numbered list
const instructions = recipe.instructions
? recipe.instructions.map((step, i) => `${i + 1}. ${step}`).join("\n")
: "";
// Render the final markdown
return fixRenderedMarkdown(`${
Object.keys(meta).length
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${recipe.name}
${recipe.meta?.image ? recipeImage : ""}
${recipe.tags.map((t) => `#${t.replaceAll(" ", "-")}`).join(" ")}
${recipe.description || ""}
---
${ingredients ? `## Ingredients\n\n${ingredients}\n\n---\n` : ""}
${instructions ? `${instructions}\n\n---` : ""}
${recipe.notes?.length ? `\n${recipe.notes.join("\n")}` : ""}
`);
}
const crud = createCrud<Recipe>({
prefix: `Recipes/`,
parse: parseRecipe,
render: renderRecipe,
hasThumbnails: true,
});
export const getAllRecipes = crud.readAll;
export const getRecipe = crud.read;
export const updateRecipe = crud.update;
export const createRecipe = crud.create;

View File

@@ -1,119 +0,0 @@
import { parseDocument } from "@lib/documents.ts";
import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
export type Series = {
id: string;
name: string;
description: string;
type: "series";
tags: string[];
meta: {
date: Date;
image: string;
author: string;
tmdbId?: number;
rating: number;
average?: string;
thumbnail?: string;
done?: boolean;
};
};
function renderSeries(series: Series) {
const meta = series.meta;
if ("date" in meta) {
meta.date = formatDate(meta.date);
}
delete meta.thumbnail;
delete meta.average;
const movieImage = `![](${series.meta.image})`;
return fixRenderedMarkdown(`${
meta
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${series.name}
${
// So we do not add a new image to the description everytime we render
(series.meta.image && !series.description.includes(movieImage))
? movieImage
: ""}
${series.tags.map((t) => `#${t}`).join(" ")}
${series.description}
`);
}
export function parseSeries(original: string, id: string): Series {
const doc = parseDocument(original);
let meta = {} as Series["meta"];
let name = "";
const range = [Infinity, -Infinity];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = (parse(child.value) || {}) as Series["meta"];
} catch (_) {
// ignore here
}
if (meta["rating"] && typeof meta["rating"] === "string") {
meta.rating = [...meta.rating?.matchAll("⭐")].length;
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (name) {
const start = child.position?.start.offset || Infinity;
const end = child.position?.end.offset || -Infinity;
if (start < range[0]) range[0] = start;
if (end > range[1]) range[1] = end;
}
}
let description = original.slice(range[0], range[1]);
const tags = extractHashTags(description);
for (const tag of tags) {
description = description.replace("#" + tag, "");
}
return {
type: "series",
id,
name,
tags,
description,
meta,
};
}
const crud = createCrud<Series>({
prefix: "Media/series/",
parse: parseSeries,
render: renderSeries,
hasThumbnails: true,
});
export const getSeries = crud.read;
export const getAllSeries = crud.readAll;
export const createSeries = crud.create;

View File

@@ -1,32 +1,27 @@
export const resources = {
"home": {
emoji: "House with Garden.png",
emoji: "home_icon.png",
name: "Home",
link: "/",
prefix: "",
},
"recipe": {
emoji: "Fork and Knife with Plate.png",
emoji: "recipes_icon.png",
name: "Recipes",
link: "/recipes",
prefix: "Recipes/",
},
"movie": {
emoji: "Popcorn.png",
emoji: "movies_icon.png",
name: "Movies",
link: "/movies",
prefix: "Media/movies/",
},
"article": {
emoji: "Writing Hand Medium-Light Skin Tone.png",
emoji: "articles_icon.png",
name: "Articles",
link: "/articles",
prefix: "Media/articles/",
},
"series": {
emoji: "Television.png",
emoji: "tv_series_icon.png",
name: "Series",
link: "/series",
prefix: "Media/series/",
},
} as const;

View File

@@ -1,11 +1,9 @@
import { resources } from "@lib/resources.ts";
import fuzzysort from "npm:fuzzysort";
import { GenericResource } from "@lib/types.ts";
import fuzzysort from "fuzzysort";
import { extractHashTags } from "@lib/string.ts";
import { getAllMovies, Movie } from "@lib/resource/movies.ts";
import { Article, getAllArticles } from "@lib/resource/articles.ts";
import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts";
import { getAllSeries, Series } from "@lib/resource/series.ts";
import { listResources } from "./marka/index.ts";
import { GenericResource } from "./marka/schema.ts";
import { parseRating } from "./helpers.ts";
type ResourceType = keyof typeof resources;
@@ -21,7 +19,7 @@ export function parseResourceUrl(_url: string | URL): SearchParams | undefined {
try {
const url = typeof _url === "string" ? new URL(_url) : _url;
let query = url.searchParams.get("q") || "*";
if (!query) {
if (!(typeof query === "string")) {
return undefined;
}
query = decodeURIComponent(query);
@@ -47,8 +45,8 @@ export function parseResourceUrl(_url: string | URL): SearchParams | undefined {
}
const isResource = (
item: Movie | Series | Article | Recipe | boolean,
): item is Movie | Series | Article | Recipe => {
item: GenericResource | boolean | undefined,
): item is GenericResource => {
return !!item;
};
@@ -56,38 +54,59 @@ export async function searchResource(
{ q, tags = [], types, rating }: SearchParams,
): Promise<GenericResource[]> {
const resources = (await Promise.all([
(!types || types.includes("movie")) && getAllMovies(),
(!types || types.includes("series")) && getAllSeries(),
(!types || types.includes("article")) && getAllArticles(),
(!types || types.includes("recipe")) && getAllRecipes(),
(!types || types.includes("movies")) && listResources("movies"),
(!types || types.includes("series")) && listResources("series"),
(!types || types.includes("articles")) && listResources("articles"),
(!types || types.includes("recipes")) && listResources("recipes"),
])).flat().filter(isResource);
console.log({ types, rating, tags, q, resourceLength: resources.length });
const results: Record<string, GenericResource> = {};
for (const resource of resources) {
if (
!(resource.id in results) &&
tags?.length && resource.tags.length &&
tags.every((t) => resource.tags.includes(t))
!(resource.name in results) &&
tags?.length && resource.content.keywords?.length &&
tags.every((t) => resource.content.keywords?.includes(t))
) {
results[resource.id] = resource;
results[resource.name] = resource;
}
// Select not-rated resources
if (
rating === 0 &&
resource.content?.reviewRating?.ratingValue === undefined
) {
results[resource.name] = resource;
}
if (
!(resource.id in results) &&
rating && resource?.meta?.rating && resource.meta.rating >= rating
typeof rating === "number" &&
rating !== 0 &&
resource.content.reviewRating?.ratingValue &&
parseRating(resource.content.reviewRating.ratingValue) >= rating
) {
results[resource.id] = resource;
results[resource.name] = resource;
}
}
if (q.length && q !== "*") {
q = decodeURIComponent(q);
const fuzzyResult = fuzzysort.go(q, resources, {
keys: ["content", "name", "description", "meta.author"],
keys: [
"name",
"content.articleBody",
"content.reviewBody",
"content.name",
"content.description",
"content.author.name",
],
threshold: 0.3,
});
console.log({ fuzzyResult });
for (const result of fuzzyResult) {
results[result.obj.id] = result.obj;
results[result.obj.name] = result.obj;
}
}

View File

@@ -1,25 +1,37 @@
import { resources } from "@lib/resources.ts";
export function formatDate(date: Date): string {
export function formatDate(date?: string | Date): string {
if (!date) return "";
if (typeof date === "string") {
try {
const d = new Date(date);
return formatDate(d);
} catch (_e) {
return "";
}
}
const options = { year: "numeric", month: "long", day: "numeric" } as const;
return new Intl.DateTimeFormat("en-US", options).format(date);
}
export function safeFileName(inputString: string): string {
let fileName = inputString.toLowerCase();
fileName = fileName.replace(/ /g, "_");
fileName = fileName.replace(/[^\w.-]/g, "");
fileName = fileName.replaceAll(":", "");
return fileName;
export function safeFileName(input: string): string {
return input
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[\s-]+/g, "_")
.replace(/-+/g, "-")
.replace(/[^A-Za-z0-9_]+/g, "")
.replace(/_+/g, "_")
// Trim underscores/dots from ends and prevent leading dots
.replace(/^[_\.]+|[_\.]+$/g, "").replace(/^\.+/, "")
.toLowerCase();
}
export function toUrlSafeString(input: string): string {
return input
.trim() // Remove leading and trailing whitespace
.toLowerCase() // Convert to lowercase
.replace(/[^a-z0-9\s-]/g, "") // Remove non-alphanumeric characters except spaces and hyphens
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-"); // Remove consecutive hyphens
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^A-Za-z0-9 _-]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
export function extractHashTags(inputString: string) {
@@ -97,12 +109,6 @@ export function getCookie(name: string): string | null {
})[0] || null;
}
const resourcePrefixes = Object.values(resources).map((v) => v.prefix).filter(
(s) => s.length > 2,
);
export const isLocalImage = (src: string) =>
resourcePrefixes.some((p) => src.startsWith(p));
export const isString = (input: string | undefined): input is string => {
return typeof input === "string";
};
@@ -180,3 +186,7 @@ export function removeMarkdownFormatting(text: string): string {
return text;
}
export function fileExtension(fname: string) {
return fname.slice((fname.lastIndexOf(".") - 1 >>> 0) + 2);
}

View File

@@ -1,6 +1,10 @@
import * as thumbhash from "https://esm.sh/thumbhash@0.1.1";
import * as thumbhash from "thumbhash";
export function generateThumbhash(buffer: Uint8Array, w: number, h: number) {
export function generateThumbhash(
buffer: ArrayLike<number>,
w: number,
h: number,
) {
const hash = thumbhash.rgbaToThumbHash(w, h, buffer);
return [hash, thumbhash.thumbHashToAverageRGBA(hash)] as const;
}

View File

@@ -5,7 +5,7 @@ import {
MovieResultsResponse,
ShowResponse,
TvResultsResponse,
} from "https://esm.sh/moviedb-promise@3.4.1";
} from "moviedb-promise";
import { createCache } from "@lib/cache.ts";
const moviedb = new MovieDb(Deno.env.get("TMDB_API_KEY") || "");

View File

@@ -1,4 +1,4 @@
import { resources } from "@lib/resources.ts";
import { GenericResource } from "./marka/schema.ts";
export interface TMDBMovie {
adult: boolean;
@@ -33,35 +33,19 @@ export interface TMDBSeries {
vote_count: number;
}
export type GenericResource = {
name: string;
id: string;
tags?: string[];
type: keyof typeof resources;
content?: string;
meta?: {
image?: string;
author?: string;
rating?: number;
average?: string;
date?: Date | string;
thumbnail?: string;
};
};
export interface GiteaOauthUser {
sub: string;
name: string;
preferred_username: string;
email: string;
picture: string;
groups: any;
groups: unknown;
}
export type SearchResult = {
id: string;
name: string;
type: keyof typeof resources;
type: GenericResource["content"]["_type"];
date?: string;
rating: number;
tags: string[];

29
lib/unsplash.ts Normal file
View File

@@ -0,0 +1,29 @@
import { UNSPLASH_API_KEY } from "./env.ts";
const API_URL = "https://api.unsplash.com";
export async function getImageBySearchTerm(
searchTerm: string,
): Promise<string | undefined> {
if (!UNSPLASH_API_KEY) {
throw new Error("UNSPLASH_API_KEY is not set");
}
const url = new URL("/search/photos", API_URL);
url.searchParams.append("query", searchTerm);
url.searchParams.append("per_page", "1");
url.searchParams.append("orientation", "landscape");
const response = await fetch(url.toString(), {
headers: {
Authorization: `Client-ID ${UNSPLASH_API_KEY}`,
},
});
if (!response.ok) {
throw new Error(`Unsplash API request failed: ${response.statusText}`);
}
const data = await response.json();
return data.results[0]?.urls?.regular;
}

View File

@@ -1,2 +1,197 @@
export function webScrape(url: URL) {
import { JSDOM } from "jsdom";
import { fetchHtmlWithPlaywright } from "./playwright.ts";
import { createStreamResponse } from "./helpers.ts";
import { Defuddle } from "defuddle/node";
import TurndownService from "turndown";
/**
* Mutates the given JSDOM instance: rewrites all relevant URL-bearing attributes
* to absolute URLs, resolving against the provided domain (e.g., "https://example.com").
*/
export function absolutizeDomUrls(dom: JSDOM, domain: string): void {
const { document } = dom.window;
const base = toBase(domain);
const rewrite = (selector: string, attr: string) => {
document.querySelectorAll<HTMLElement>(selector).forEach(
(el: HTMLElement) => {
const v = el.getAttribute(attr);
if (!v) return;
const abs = toAbsolute(v, base);
if (abs !== v) el.setAttribute(attr, abs);
},
);
};
// Common URL attributes
rewrite("a[href]", "href");
rewrite("area[href]", "href");
rewrite("link[href]", "href");
rewrite("use[href]", "href"); // SVG 2
rewrite("use[xlink\\:href]", "xlink:href"); // legacy SVG
rewrite("image[href]", "href"); // SVG
rewrite("image[xlink\\:href]", "xlink:href"); // legacy SVG
rewrite("script[src]", "src");
rewrite("img[src]", "src");
rewrite("source[src]", "src");
rewrite("track[src]", "src");
rewrite("iframe[src]", "src");
rewrite("embed[src]", "src");
rewrite("audio[src]", "src");
rewrite("video[src]", "src");
rewrite("object[data]", "data");
rewrite("input[src]", "src");
rewrite("form[action]", "action");
rewrite("video[poster]", "poster");
document
.querySelectorAll("img[srcset], source[srcset]")
.forEach((el) => {
const v = el.getAttribute("srcset");
if (!v) return;
const abs = absolutizeSrcset(v, base);
if (abs !== v) el.setAttribute("srcset", abs);
});
document.querySelectorAll("[style]").forEach(
(el) => {
const v = el.getAttribute("style");
if (!v) return;
const abs = absolutizeCssUrls(v, base);
if (abs !== v) el.setAttribute("style", abs);
},
);
document.querySelectorAll("style").forEach(
(styleEl: HTMLStyleElement) => {
const css = styleEl.textContent ?? "";
const abs = absolutizeCssUrls(css, base);
if (abs !== css) styleEl.textContent = abs;
},
);
document
.querySelectorAll('meta[http-equiv="refresh" i][content]')
.forEach((meta) => {
const content = meta.getAttribute("content") || "";
const abs = absolutizeMetaRefresh(content, base);
if (abs !== content) meta.setAttribute("content", abs);
});
}
/** Normalize the base to a valid absolute URL root. */
function toBase(domain: string): string {
// Allow callers to pass "example.com" or "//example.com"
let d = domain.trim();
if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(d)) {
d = d.startsWith("//") ? `https:${d}` : `https://${d}`;
}
// Ensure trailing slash does not matter for URL resolution
try {
// new URL('/', base) works whether base ends with slash or not
return new URL("/", d).toString();
} catch {
// Fallback: if domain is irreparably bad, throw early
throw new Error(`Invalid base domain: ${domain}`);
}
}
/** Convert a possibly-relative URL to absolute, using the provided base. */
function toAbsolute(url: string, base: string): string {
const trimmed = url.trim();
// Leave already absolute or special schemes untouched by just parsing directly.
// If it's not a valid absolute URL, resolve against base.
try {
// If parse succeeds without base and includes a scheme, keep as-is
const abs = new URL(trimmed);
return abs.toString();
} catch {
// Not absolute, resolve relative to base (handles #hash, ?q, //host, etc.)
try {
return new URL(trimmed, base).toString();
} catch {
// If still invalid (e.g., badly formed), return original
return url;
}
}
}
/** Absolutize a srcset list. */
function absolutizeSrcset(srcset: string, base: string): string {
// Split by commas but keep descriptors (1x, 2x, 100w, etc.)
// Each candidate: <url> [<descriptor>]
return srcset
.split(",")
.map((part) => {
const s = part.trim();
if (!s) return s;
// First whitespace separates URL and descriptor
const spaceIdx = s.search(/\s/);
if (spaceIdx === -1) {
return toAbsolute(s, base);
}
const url = s.slice(0, spaceIdx);
const desc = s.slice(spaceIdx).trim();
return `${toAbsolute(url, base)} ${desc}`;
})
.join(", ");
}
/** Replace url(...) in CSS text with absolute URLs. */
function absolutizeCssUrls(cssText: string, base: string): string {
// Matches url("..."), url('...'), url(...)
return cssText.replace(
/url\(\s*(['"]?)([^'")]+)\1\s*\)/g,
(_m, _q, rawUrl) => {
const abs = toAbsolute(rawUrl, base);
// Preserve quoting if present; browsers accept unquoted if safe, but keep simple.
return `url(${abs})`;
},
);
}
/** Rewrite the URL in a meta refresh content value if present. */
function absolutizeMetaRefresh(content: string, base: string): string {
// Format examples:
// "5; url=/path", "0;URL='page.html'"
const match = content.match(
/^\s*([^;]+)\s*;\s*(url|URL)\s*=\s*('?)([^']+)\3\s*$/,
);
if (!match) return content;
const delay = match[1].trim();
const url = match[4].trim();
const abs = toAbsolute(url, base);
return `${delay}; url=${abs}`;
}
const turndownService = new TurndownService();
export interface WebScrapeResult {
title?: string;
image?: string;
published?: string;
content: string;
schemaOrgData?: { author?: { name?: string } };
markdown: string;
dom: JSDOM["window"]["document"];
}
export async function webScrape(
url: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
const u = new URL(url);
const html = await fetchHtmlWithPlaywright(url, streamResponse);
const dom = new JSDOM(html);
absolutizeDomUrls(dom, u.origin);
const result = await Defuddle(dom, url);
return {
...result,
dom: dom.window.document,
markdown: turndownService.turndown(result.content),
};
}

View File

@@ -56,12 +56,10 @@ export interface ContentDetails {
definition: string;
caption: string;
licensedContent: boolean;
contentRating: ContentRating;
contentRating: Record<string, unknown>;
projection: string;
}
export interface ContentRating {}
export interface Statistics {
viewCount: string;
likeCount: string;
@@ -74,6 +72,11 @@ export interface PageInfo {
resultsPerPage: number;
}
export async function getYoutubeVideoCover(id: string): Promise<ArrayBuffer> {
const res = await fetch(`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`);
return res.arrayBuffer();
}
export async function getYoutubeVideoDetails(
id: string,
): Promise<Item> {
@@ -81,6 +84,5 @@ export async function getYoutubeVideoDetails(
`${BASE_URL}videos?part=snippet%2CcontentDetails%2Cstatistics&id=${id}&key=${YOUTUBE_API_KEY}`,
);
const json = await response.json();
return json?.items[0];
}

15
main.ts
View File

@@ -1,11 +1,8 @@
/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import { App, staticFiles } from "fresh";
import { type State } from "./utils.ts";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
export const app = new App<State>();
await start(manifest, config);
app.use(staticFiles());
app.fsRoutes();

View File

@@ -1,23 +0,0 @@
import { Head } from "$fresh/runtime.ts";
import { MainLayout } from "@components/layouts/main.tsx";
export default function Error404() {
return (
<>
<Head>
<title>404 - Page not found</title>
</Head>
<MainLayout>
<div class="px-8 text-white mt-10">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<h1 class="text-4xl font-bold">404 - Page not found</h1>
<p class="my-4">
The page you were looking for doesn't exist.
</p>
<a href="/" class="underline">Go back home</a>
</div>
</div>
</MainLayout>
</>
);
}

View File

@@ -1,35 +1,47 @@
import { PageProps } from "$fresh/server.ts";
import { Partial } from "$fresh/runtime.ts";
import { useEffect } from "preact/hooks";
export default function App({ Component }: PageProps) {
const globalCss = Deno
.readTextFileSync("./static/global.css")
.replaceAll("\n", "");
import { define } from "../utils.ts";
export default define.page(function ({ Component }) {
return (
<html>
<head>
<link rel="stylesheet" href="/prism-material-dark.css" />
<link rel="stylesheet" href="/styles.css" />
<link
rel="icon"
type="image/png"
href="/favicon.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link
rel="preload"
href="/fonts/work-sans-v18-latin-regular.woff2"
as="font"
type="font/woff2"
/>
<link
rel="preload"
href="/fonts/work-sans-v18-latin-700.woff2"
as="font"
type="font/woff2"
/>
<link rel="stylesheet" href="/global.css" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#141218" />
<style>{globalCss}</style>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style
dangerouslySetInnerHTML={{
__html: Deno.readTextFileSync("./static/global.css"),
}}
>
</style>
<title>Memorium</title>
</head>
<body f-client-nav>
<Partial name="body">
<body>
<Component />
</Partial>
</body>
<script src="/thumbnails.js" type="module" async defer />
</html>
);
}
});

34
routes/_error.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { Head } from "fresh/runtime";
import { MainLayout } from "@components/layouts/main.tsx";
import { HttpError, PageProps } from "fresh";
export default function ErrorPage(props: PageProps) {
const error = props.error; // Contains the thrown Error or HTTPError
if (error instanceof HttpError) {
const status = error.status; // HTTP status code
// Render a 404 not found page
if (status === 404) {
return <h1>404 - Page not found</h1>;
}
}
return (
<>
<Head>
<title>404 - Page not found</title>
</Head>
<MainLayout url="">
<div class="px-8 text-white mt-10">
<div class="max-w-3xl mx-auto flex flex-col items-center justify-center">
<h1 class="text-4xl font-bold">404 - Page not found</h1>
<p class="my-4">
The page you were looking for doesn't exist.
</p>
<a href="/" class="underline">Go back home</a>
</div>
</div>
</MainLayout>
</>
);
}

View File

@@ -1,9 +1,10 @@
import { PageProps } from "$fresh/server.ts";
import { resources } from "@lib/resources.ts";
import { Link } from "@islands/Link.tsx";
import { Emoji } from "@components/Emoji.tsx";
import KMenuButton from "@islands/KMenuButton.tsx";
import { PageProps } from "fresh";
import { define } from "../utils.ts";
export default function MyLayout({ Component }: PageProps) {
export default define.layout(function ({ Component }: PageProps) {
return (
<div
class="md:grid mx-auto"
@@ -13,22 +14,20 @@ export default function MyLayout({ Component }: PageProps) {
<nav class="min-h-fit rounded-3xl p-3 grid gap-3 fixed t-0">
{Object.values(resources).map((m) => {
return (
<Link
<a
href={m.link}
class="flex items-center gap-2 text-white data-[current]:bg-white data-[current]:text-black p-3 text-xl w-full rounded-2xl"
class="flex items-center gap-2 text-white data-current:bg-white data-current:text-black p-3 text-xl w-full rounded-2xl"
>
<Emoji class="w-6 h-6" name={m.emoji} /> {m.name}
</Link>
</a>
);
})}
</nav>
</aside>
<main
class="py-5"
style={{ fontFamily: "Work Sans" }}
>
<main class="py-5">
<Component />
</main>
<KMenuButton />
</div>
);
}
});

View File

@@ -1,27 +1,39 @@
//routes/middleware-error-handler/_middleware.ts
import { FreshContext } from "$fresh/server.ts";
import { DomainError } from "@lib/errors.ts";
import { getCookies } from "@std/http/cookie";
import { verify } from "https://deno.land/x/djwt@v2.2/mod.ts";
import { verify } from "@zaubrik/djwt";
import * as perf from "@lib/performance.ts";
import { JWT_SECRET } from "@lib/env.ts";
import { define } from "../utils.ts";
export async function handler(
req: Request,
ctx: FreshContext,
function importKey(secret: string) {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-512" },
false,
["sign", "verify"],
);
}
const authMiddleware = define.middleware(async function (
ctx,
) {
const req = ctx.req;
try {
performance.mark("a");
const allCookies = getCookies(req.headers);
const sessionCookie = allCookies["session_cookie"];
if (!ctx.state.session && sessionCookie && JWT_SECRET) {
try {
const payload = await verify(sessionCookie, JWT_SECRET, "HS512");
const payload = await verify<typeof ctx.state.session>(
sessionCookie,
await importKey(JWT_SECRET),
);
if (payload) {
ctx.state.session = payload;
}
} catch (_err) {
//
console.log({ _err });
}
}
@@ -44,4 +56,6 @@ export async function handler(
status: 500,
});
}
}
});
export default [authMiddleware];

View File

@@ -1,32 +0,0 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getCacheInfo } from "@lib/cache.ts";
export const handler: Handlers<
{ cacheInfo: ReturnType<typeof getCacheInfo> }
> = {
GET(_, ctx) {
return ctx.render({ cacheInfo: getCacheInfo() });
},
};
export default function Greet(
props: PageProps<
{ cacheInfo: ReturnType<typeof getCacheInfo> }
>,
) {
const { cacheInfo } = props.data;
return (
<MainLayout
url={props.url}
title="Recipes"
context={{ type: "recipe" }}
>
<code>
<pre class="text-white">
{JSON.stringify(cacheInfo, null, 2)}
</pre>
</code>
</MainLayout>
);
}

Some files were not shown because too many files have changed in this diff Show More