Skip to content

Commit

Permalink
[ui] Very first draft of the link grid
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Feb 7, 2024
1 parent c3ecb08 commit daebbf0
Show file tree
Hide file tree
Showing 14 changed files with 248 additions and 52 deletions.
10 changes: 9 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 file modified bun.lockb
Binary file not shown.
5 changes: 4 additions & 1 deletion db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
"version": "0.1.0",
"private": true,
"main": "index.ts",
"dependencies": {}
"dependencies": {
"prisma": "^5.9.1",
"@prisma/client": "^5.9.1"
}
}
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 4 additions & 32 deletions web/app/api/v1/links/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });
Expand All @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions web/app/bookmarks/components/AddLink.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
19 changes: 19 additions & 0 deletions web/app/bookmarks/components/LinkCard.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
21 changes: 21 additions & 0 deletions web/app/bookmarks/components/LinksGrid.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
11 changes: 11 additions & 0 deletions web/app/bookmarks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import AddLink from "./components/AddLink";
import LinksGrid from "./components/LinksGrid";

export default async function Bookmarks() {
return (
<>
<AddLink />
<LinksGrid />
</>
);
}
16 changes: 4 additions & 12 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -20,7 +12,7 @@ export default function Home() {
<LogoutButton />
<br />
<br />
<button onClick={addUrl}>Add URL</button>
<Link href="/bookmarks">Bookmarks</Link>
</div>
</main>
);
Expand Down
87 changes: 87 additions & 0 deletions web/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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),
});
}
}
1 change: 1 addition & 0 deletions web/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function buildAuthentikConfig() {
}

const serverConfig = {
api_url: process.env.API_URL || "http://localhost:3000",
auth: {
authentik: buildAuthentikConfig(),
},
Expand Down
40 changes: 40 additions & 0 deletions web/lib/services/links.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
},
});
}
11 changes: 7 additions & 4 deletions web/lib/types/api/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand All @@ -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({
Expand Down

0 comments on commit daebbf0

Please sign in to comment.