diff options
| -rw-r--r-- | .eslintrc.json | 10 | ||||
| -rwxr-xr-x | bun.lockb | bin | 241424 -> 241872 bytes | |||
| -rw-r--r-- | db/package.json | 5 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | web/app/api/v1/links/route.ts | 36 | ||||
| -rw-r--r-- | web/app/bookmarks/components/AddLink.tsx | 40 | ||||
| -rw-r--r-- | web/app/bookmarks/components/LinkCard.tsx | 19 | ||||
| -rw-r--r-- | web/app/bookmarks/components/LinksGrid.tsx | 21 | ||||
| -rw-r--r-- | web/app/bookmarks/page.tsx | 11 | ||||
| -rw-r--r-- | web/app/page.tsx | 16 | ||||
| -rw-r--r-- | web/lib/api.ts | 87 | ||||
| -rw-r--r-- | web/lib/config.ts | 1 | ||||
| -rw-r--r-- | web/lib/services/links.ts | 40 | ||||
| -rw-r--r-- | web/lib/types/api/links.ts | 11 |
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": "^_" + } + ] } } Binary files differdiff --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({ |
