aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json10
-rwxr-xr-xbun.lockbbin241424 -> 241872 bytes
-rw-r--r--db/package.json5
-rw-r--r--package.json3
-rw-r--r--web/app/api/v1/links/route.ts36
-rw-r--r--web/app/bookmarks/components/AddLink.tsx40
-rw-r--r--web/app/bookmarks/components/LinkCard.tsx19
-rw-r--r--web/app/bookmarks/components/LinksGrid.tsx21
-rw-r--r--web/app/bookmarks/page.tsx11
-rw-r--r--web/app/page.tsx16
-rw-r--r--web/lib/api.ts87
-rw-r--r--web/lib/config.ts1
-rw-r--r--web/lib/services/links.ts40
-rw-r--r--web/lib/types/api/links.ts11
14 files changed, 248 insertions, 52 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index c580ca7e..26977516 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -11,6 +11,14 @@
"ignorePatterns": ["postcss.config.js"],
"rules": {
"no-redeclare": "off",
- "@next/next/no-html-link-for-pages": "off"
+ "@next/next/no-html-link-for-pages": "off",
+ "no-undef": "off",
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_",
+ "argsIgnorePattern": "^_"
+ }
+ ]
}
}
diff --git a/bun.lockb b/bun.lockb
index 41419b10..fc5566d0 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differ
diff --git a/db/package.json b/db/package.json
index a10a450b..b5222f8a 100644
--- a/db/package.json
+++ b/db/package.json
@@ -4,5 +4,8 @@
"version": "0.1.0",
"private": true,
"main": "index.ts",
- "dependencies": {}
+ "dependencies": {
+ "prisma": "^5.9.1",
+ "@prisma/client": "^5.9.1"
+ }
}
diff --git a/package.json b/package.json
index 8e6b98ac..c57be20a 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,7 @@
"class-variance-authority": "^0.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.33.2",
- "eslint-plugin-react-hooks": "^4.6.0",
- "prisma": "^5.9.1"
+ "eslint-plugin-react-hooks": "^4.6.0"
},
"devDependencies": {
"typescript": "^5",
diff --git a/web/app/api/v1/links/route.ts b/web/app/api/v1/links/route.ts
index 990b6c02..87541634 100644
--- a/web/app/api/v1/links/route.ts
+++ b/web/app/api/v1/links/route.ts
@@ -1,6 +1,5 @@
import { authOptions } from "@/lib/auth";
-import { LinkCrawlerQueue } from "@remember/shared/queues";
-import prisma from "@remember/db";
+import { bookmarkLink, getLinks } from "@/lib/services/links";
import {
zNewBookmarkedLinkRequestSchema,
@@ -30,18 +29,7 @@ export async function POST(request: NextRequest) {
);
}
- const link = await prisma.bookmarkedLink.create({
- data: {
- url: linkRequest.data.url,
- userId: session.user.id,
- },
- });
-
- // Enqueue crawling request
- await LinkCrawlerQueue.add("crawl", {
- linkId: link.id,
- url: link.url,
- });
+ const link = await bookmarkLink(linkRequest.data.url, session.user.id);
let response: ZBookmarkedLink = { ...link };
return NextResponse.json(response, { status: 201 });
@@ -53,24 +41,8 @@ export async function GET() {
if (!session) {
return new Response(null, { status: 401 });
}
- const links = await prisma.bookmarkedLink.findMany({
- where: {
- userId: session.user.id,
- },
- select: {
- id: true,
- url: true,
- createdAt: true,
- details: {
- select: {
- title: true,
- description: true,
- imageUrl: true,
- favicon: true,
- },
- },
- },
- });
+
+ const links = await getLinks(session.user.id);
let response: ZGetLinksResponse = { links };
return NextResponse.json(response);
diff --git a/web/app/bookmarks/components/AddLink.tsx b/web/app/bookmarks/components/AddLink.tsx
new file mode 100644
index 00000000..54cf9137
--- /dev/null
+++ b/web/app/bookmarks/components/AddLink.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import APIClient from "@/lib/api";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+export default function AddLink() {
+ const router = useRouter();
+ const [link, setLink] = useState("");
+
+ const bookmarkLink = async () => {
+ const [_resp, error] = await APIClient.bookmarkLink(link);
+ if (error) {
+ alert(error.message);
+ return;
+ }
+ router.refresh();
+ };
+
+ return (
+ <div className="p-4">
+ <input
+ type="text"
+ placeholder="Link"
+ value={link}
+ onChange={(val) => setLink(val.target.value)}
+ onKeyUp={async (event) => {
+ if (event.key == "Enter") {
+ bookmarkLink();
+ setLink("");
+ }
+ }}
+ className="w-10/12 px-4 py-2 border rounded-md focus:outline-none focus:border-blue-300"
+ />
+ <button className="w-2/12 px-1 py-2" onClick={bookmarkLink}>
+ Submit
+ </button>
+ </div>
+ );
+}
diff --git a/web/app/bookmarks/components/LinkCard.tsx b/web/app/bookmarks/components/LinkCard.tsx
new file mode 100644
index 00000000..103f97ef
--- /dev/null
+++ b/web/app/bookmarks/components/LinkCard.tsx
@@ -0,0 +1,19 @@
+import { ZBookmarkedLink } from "@/lib/types/api/links";
+import Link from "next/link";
+
+export default async function LinkCard({ link }: { link: ZBookmarkedLink }) {
+ return (
+ <Link href={link.url} className="border rounded-md hover:border-blue-300">
+ <div className="p-4">
+ <h2 className="text-lg font-semibold">
+ {link.details?.favicon && (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img alt="" width="10" height="10" src={link.details?.favicon} />
+ )}
+ {link.details?.title ?? link.id}
+ </h2>
+ <p className="text-gray-600">{link.details?.description ?? link.url}</p>
+ </div>
+ </Link>
+ );
+}
diff --git a/web/app/bookmarks/components/LinksGrid.tsx b/web/app/bookmarks/components/LinksGrid.tsx
new file mode 100644
index 00000000..83aaca80
--- /dev/null
+++ b/web/app/bookmarks/components/LinksGrid.tsx
@@ -0,0 +1,21 @@
+import { getServerSession } from "next-auth";
+import { redirect } from "next/navigation";
+import { authOptions } from "@/lib/auth";
+import { getLinks } from "@/lib/services/links";
+import LinkCard from "./LinkCard";
+
+export default async function LinksGrid() {
+ const session = await getServerSession(authOptions);
+ if (!session) {
+ redirect("/");
+ }
+ const links = await getLinks(session.user.id);
+
+ return (
+ <div className="container mx-auto mt-8 grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
+ {links.map((l) => (
+ <LinkCard key={l.id} link={l} />
+ ))}
+ </div>
+ );
+}
diff --git a/web/app/bookmarks/page.tsx b/web/app/bookmarks/page.tsx
new file mode 100644
index 00000000..f0efa2e4
--- /dev/null
+++ b/web/app/bookmarks/page.tsx
@@ -0,0 +1,11 @@
+import AddLink from "./components/AddLink";
+import LinksGrid from "./components/LinksGrid";
+
+export default async function Bookmarks() {
+ return (
+ <>
+ <AddLink />
+ <LinksGrid />
+ </>
+ );
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
index b78fe389..ffc128a5 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -1,16 +1,8 @@
-"use client";
-
-import { useCallback } from "react";
-import { LoginButton } from "../components/auth/login";
-import { LogoutButton } from "../components/auth/logout";
+import { LoginButton } from "@/components/auth/login";
+import { LogoutButton } from "@/components/auth/logout";
+import Link from "next/link";
export default function Home() {
- const addUrl = useCallback(async () => {
- await fetch("/api/v1/links", {
- method: "POST",
- body: JSON.stringify({ url: "https://news.ycombinator.com/news" }),
- });
- }, []);
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
@@ -20,7 +12,7 @@ export default function Home() {
<LogoutButton />
<br />
<br />
- <button onClick={addUrl}>Add URL</button>
+ <Link href="/bookmarks">Bookmarks</Link>
</div>
</main>
);
diff --git a/web/lib/api.ts b/web/lib/api.ts
new file mode 100644
index 00000000..193d9bb7
--- /dev/null
+++ b/web/lib/api.ts
@@ -0,0 +1,87 @@
+"use client";
+
+import { ZodTypeAny, z } from "zod";
+import {
+ ZNewBookmarkedLinkRequest,
+ zGetLinksResponseSchema,
+} from "./types/api/links";
+
+import serverConfig from "./config";
+
+const BASE_URL = `${serverConfig.api_url}/api/v1`;
+
+export type FetchError = {
+ status?: number;
+ message?: string;
+};
+
+async function doRequest<Schema extends ZodTypeAny>(
+ _path: string,
+ respSchema: Schema,
+ _opts: RequestInit | undefined,
+): Promise<[z.infer<typeof respSchema>, undefined] | [undefined, FetchError]>;
+
+async function doRequest<_Schema>(
+ _path: string,
+ _respSchema: undefined,
+ _opts: RequestInit | undefined,
+): Promise<[undefined, undefined] | [undefined, FetchError]>;
+
+type InputSchema<T> = T extends ZodTypeAny ? T : undefined;
+
+async function doRequest<T>(
+ path: string,
+ respSchema?: InputSchema<T>,
+ opts?: RequestInit,
+): Promise<
+ | (InputSchema<T> extends ZodTypeAny
+ ? [z.infer<InputSchema<T>>, undefined]
+ : [undefined, undefined])
+ | [undefined, FetchError]
+> {
+ try {
+ const res = await fetch(`${BASE_URL}${path}`, opts);
+ if (!res.ok) {
+ return [
+ undefined,
+ { status: res.status, message: await res.text() },
+ ] as const;
+ }
+ if (!respSchema) {
+ return [undefined, undefined] as const;
+ }
+
+ let parsed = respSchema.safeParse(await res.json());
+ if (!parsed.success) {
+ return [
+ undefined,
+ { message: `Failed to parse response: ${parsed.error.toString()}` },
+ ] as const;
+ }
+
+ return [parsed.data, undefined] as const;
+ } catch (error: any) {
+ return [
+ undefined,
+ { message: `Failed to execute fetch request: ${error}` },
+ ] as const;
+ }
+}
+
+export default class APIClient {
+ static async getLinks() {
+ return await doRequest(`/links`, zGetLinksResponseSchema, {
+ next: { tags: ["links"] },
+ });
+ }
+
+ static async bookmarkLink(url: string) {
+ const body: ZNewBookmarkedLinkRequest = {
+ url,
+ };
+ return await doRequest(`/links`, undefined, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ }
+}
diff --git a/web/lib/config.ts b/web/lib/config.ts
index 6b55aa2c..ec042b54 100644
--- a/web/lib/config.ts
+++ b/web/lib/config.ts
@@ -13,6 +13,7 @@ function buildAuthentikConfig() {
}
const serverConfig = {
+ api_url: process.env.API_URL || "http://localhost:3000",
auth: {
authentik: buildAuthentikConfig(),
},
diff --git a/web/lib/services/links.ts b/web/lib/services/links.ts
new file mode 100644
index 00000000..f3ff1757
--- /dev/null
+++ b/web/lib/services/links.ts
@@ -0,0 +1,40 @@
+import { LinkCrawlerQueue } from "@remember/shared/queues";
+import prisma from "@remember/db";
+
+export async function bookmarkLink(url: string, userId: string) {
+ const link = await prisma.bookmarkedLink.create({
+ data: {
+ url,
+ userId,
+ },
+ });
+
+ // Enqueue crawling request
+ await LinkCrawlerQueue.add("crawl", {
+ linkId: link.id,
+ url: link.url,
+ });
+
+ return link;
+}
+
+export async function getLinks(userId: string) {
+ return await prisma.bookmarkedLink.findMany({
+ where: {
+ userId,
+ },
+ select: {
+ id: true,
+ url: true,
+ createdAt: true,
+ details: {
+ select: {
+ title: true,
+ description: true,
+ imageUrl: true,
+ favicon: true,
+ },
+ },
+ },
+ });
+}
diff --git a/web/lib/types/api/links.ts b/web/lib/types/api/links.ts
index 48214f9a..644589b4 100644
--- a/web/lib/types/api/links.ts
+++ b/web/lib/types/api/links.ts
@@ -7,10 +7,10 @@ export const zBookmarkedLinkSchema = z.object({
details: z
.object({
- title: z.string(),
- description: z.string().optional(),
- imageUrl: z.string().url().optional(),
- favicon: z.string().url().optional(),
+ title: z.string().nullish(),
+ description: z.string().nullish(),
+ imageUrl: z.string().url().nullish(),
+ favicon: z.string().url().nullish(),
})
.nullish(),
});
@@ -20,6 +20,9 @@ export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>;
export const zNewBookmarkedLinkRequestSchema = zBookmarkedLinkSchema.pick({
url: true,
});
+export type ZNewBookmarkedLinkRequest = z.infer<
+ typeof zNewBookmarkedLinkRequestSchema
+>;
// GET /v1/links
export const zGetLinksResponseSchema = z.object({