aboutsummaryrefslogtreecommitdiffstats
path: root/packages/web/lib
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web/lib')
-rw-r--r--packages/web/lib/api.ts81
-rw-r--r--packages/web/lib/auth.ts25
-rw-r--r--packages/web/lib/config.ts22
-rw-r--r--packages/web/lib/services/links.ts78
-rw-r--r--packages/web/lib/types/api/links.ts33
-rw-r--r--packages/web/lib/types/api/tags.ts6
-rw-r--r--packages/web/lib/types/next-auth.d.ts12
-rw-r--r--packages/web/lib/utils.ts6
8 files changed, 263 insertions, 0 deletions
diff --git a/packages/web/lib/api.ts b/packages/web/lib/api.ts
new file mode 100644
index 00000000..56686cde
--- /dev/null
+++ b/packages/web/lib/api.ts
@@ -0,0 +1,81 @@
+"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;
+};
+
+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),
+ });
+ }
+
+ static async unbookmarkLink(linkId: string) {
+ return await doRequest(`/links/${linkId}`, undefined, {
+ method: "DELETE",
+ });
+ }
+}
diff --git a/packages/web/lib/auth.ts b/packages/web/lib/auth.ts
new file mode 100644
index 00000000..cd6404de
--- /dev/null
+++ b/packages/web/lib/auth.ts
@@ -0,0 +1,25 @@
+import NextAuth, { NextAuthOptions } from "next-auth";
+import { PrismaAdapter } from "@next-auth/prisma-adapter";
+import AuthentikProvider from "next-auth/providers/authentik";
+import serverConfig from "@/lib/config";
+import prisma from "@remember/db";
+
+let providers = [];
+
+if (serverConfig.auth.authentik) {
+ providers.push(AuthentikProvider(serverConfig.auth.authentik));
+}
+
+export const authOptions: NextAuthOptions = {
+ // Configure one or more authentication providers
+ adapter: PrismaAdapter(prisma),
+ providers: providers,
+ callbacks: {
+ session({ session, user }) {
+ session.user = { ...user };
+ return session;
+ },
+ },
+};
+
+export const authHandler = NextAuth(authOptions);
diff --git a/packages/web/lib/config.ts b/packages/web/lib/config.ts
new file mode 100644
index 00000000..ec042b54
--- /dev/null
+++ b/packages/web/lib/config.ts
@@ -0,0 +1,22 @@
+function buildAuthentikConfig() {
+ let { AUTHENTIK_ID, AUTHENTIK_SECRET, AUTHENTIK_ISSUER } = process.env;
+
+ if (!AUTHENTIK_ID || !AUTHENTIK_SECRET || !AUTHENTIK_ISSUER) {
+ return undefined;
+ }
+
+ return {
+ clientId: AUTHENTIK_ID,
+ clientSecret: AUTHENTIK_SECRET,
+ issuer: AUTHENTIK_ISSUER,
+ };
+}
+
+const serverConfig = {
+ api_url: process.env.API_URL || "http://localhost:3000",
+ auth: {
+ authentik: buildAuthentikConfig(),
+ },
+};
+
+export default serverConfig;
diff --git a/packages/web/lib/services/links.ts b/packages/web/lib/services/links.ts
new file mode 100644
index 00000000..d273b118
--- /dev/null
+++ b/packages/web/lib/services/links.ts
@@ -0,0 +1,78 @@
+import { LinkCrawlerQueue } from "@remember/shared/queues";
+import prisma from "@remember/db";
+import { ZBookmarkedLink } from "@/lib/types/api/links";
+
+const defaultLinkFields = {
+ id: true,
+ url: true,
+ createdAt: true,
+ details: {
+ select: {
+ title: true,
+ description: true,
+ imageUrl: true,
+ favicon: true,
+ },
+ },
+ tags: {
+ include: {
+ tag: true,
+ },
+ },
+};
+
+async function dummyPrismaReturnType() {
+ return await prisma.bookmarkedLink.findFirstOrThrow({
+ select: defaultLinkFields,
+ });
+}
+
+function toZodSchema(
+ link: Awaited<ReturnType<typeof dummyPrismaReturnType>>,
+): ZBookmarkedLink {
+ return {
+ id: link.id,
+ url: link.url,
+ createdAt: link.createdAt,
+ details: link.details,
+ tags: link.tags.map((t) => t.tag),
+ };
+}
+
+export async function unbookmarkLink(linkId: string, userId: string) {
+ await prisma.bookmarkedLink.delete({
+ where: {
+ id: linkId,
+ userId,
+ },
+ });
+}
+
+export async function bookmarkLink(url: string, userId: string) {
+ const link = await prisma.bookmarkedLink.create({
+ data: {
+ url,
+ userId,
+ },
+ select: defaultLinkFields,
+ });
+
+ // Enqueue crawling request
+ await LinkCrawlerQueue.add("crawl", {
+ linkId: link.id,
+ url: link.url,
+ });
+
+ return toZodSchema(link);
+}
+
+export async function getLinks(userId: string) {
+ return (
+ await prisma.bookmarkedLink.findMany({
+ where: {
+ userId,
+ },
+ select: defaultLinkFields,
+ })
+ ).map(toZodSchema);
+}
diff --git a/packages/web/lib/types/api/links.ts b/packages/web/lib/types/api/links.ts
new file mode 100644
index 00000000..f84445f6
--- /dev/null
+++ b/packages/web/lib/types/api/links.ts
@@ -0,0 +1,33 @@
+import { z } from "zod";
+import { zBookmarkTagSchema } from "@/lib/types/api/tags";
+
+export const zBookmarkedLinkSchema = z.object({
+ id: z.string(),
+ url: z.string().url(),
+ createdAt: z.coerce.date(),
+
+ details: z
+ .object({
+ title: z.string().nullish(),
+ description: z.string().nullish(),
+ imageUrl: z.string().url().nullish(),
+ favicon: z.string().url().nullish(),
+ })
+ .nullish(),
+ tags: z.array(zBookmarkTagSchema),
+});
+export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>;
+
+// POST /v1/links
+export const zNewBookmarkedLinkRequestSchema = zBookmarkedLinkSchema.pick({
+ url: true,
+});
+export type ZNewBookmarkedLinkRequest = z.infer<
+ typeof zNewBookmarkedLinkRequestSchema
+>;
+
+// GET /v1/links
+export const zGetLinksResponseSchema = z.object({
+ links: z.array(zBookmarkedLinkSchema),
+});
+export type ZGetLinksResponse = z.infer<typeof zGetLinksResponseSchema>;
diff --git a/packages/web/lib/types/api/tags.ts b/packages/web/lib/types/api/tags.ts
new file mode 100644
index 00000000..f2d2bc18
--- /dev/null
+++ b/packages/web/lib/types/api/tags.ts
@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const zBookmarkTagSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+});
diff --git a/packages/web/lib/types/next-auth.d.ts b/packages/web/lib/types/next-auth.d.ts
new file mode 100644
index 00000000..cd47dfce
--- /dev/null
+++ b/packages/web/lib/types/next-auth.d.ts
@@ -0,0 +1,12 @@
+import { DefaultSession } from "next-auth";
+
+declare module "next-auth" {
+ /**
+ * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
+ */
+ export interface Session {
+ user: {
+ id: string;
+ } & DefaultSession["user"];
+ }
+}
diff --git a/packages/web/lib/utils.ts b/packages/web/lib/utils.ts
new file mode 100644
index 00000000..365058ce
--- /dev/null
+++ b/packages/web/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}