Big edit: Got auth working and can sign into a dashboard. Lot of crust to delete though.

This commit is contained in:
Ryan Pandya 2024-11-14 17:32:02 -08:00
parent c023fa1730
commit 0fd8ce7189
248 changed files with 19382 additions and 1499 deletions

File diff suppressed because it is too large Load Diff

View File

@ -26,8 +26,8 @@
"@lifetracker/eslint-config": "workspace:*",
"@lifetracker/typescript-config": "workspace:*",
"@lifetracker/ui": "workspace:*",
"@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2",
"@trpc/client": "^11.0.0-next-beta.308",
"@trpc/server": "^11.0.0-next-beta.308",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"superjson": "^2.2.1",

View File

@ -1,6 +1,6 @@
import { getGlobalOptions } from "@/lib/globals";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@lifetracker/trpc/routers/_app";
export function getAPIClient() {
@ -15,7 +15,7 @@ export function getAPIClient() {
authorization: `Bearer ${globals.apiKey}`,
};
},
}),
},),
],
});
}

View File

@ -1,9 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@lifetracker/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};

36
apps/web/.gitignore vendored
View File

@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,4 +1,4 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app).
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
@ -18,7 +18,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
@ -27,10 +27,10 @@ To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -0,0 +1,56 @@
import { createContextFromRequest } from "@/server/api/client";
import { and, eq } from "drizzle-orm";
import { assets } from "@lifetracker/db/schema";
import { readAsset } from "@lifetracker/shared/assetdb";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: { assetId: string } },
) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const assetDb = await ctx.db.query.assets.findFirst({
where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)),
});
if (!assetDb) {
return Response.json({ error: "Asset not found" }, { status: 404 });
}
const { asset, metadata } = await readAsset({
userId: ctx.user.id,
assetId: params.assetId,
});
const range = request.headers.get("Range");
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : asset.length - 1;
// TODO: Don't read the whole asset into memory in the first place
const chunk = asset.subarray(start, end + 1);
return new Response(chunk, {
status: 206, // Partial Content
headers: {
"Content-Range": `bytes ${start}-${end}/${asset.length}`,
"Accept-Ranges": "bytes",
"Content-Length": chunk.length.toString(),
"Content-type": metadata.contentType,
},
});
} else {
return new Response(asset, {
status: 200,
headers: {
"Content-Length": asset.length.toString(),
"Content-type": metadata.contentType,
},
});
}
}

View File

@ -0,0 +1,77 @@
import { createContextFromRequest } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
import type { ZUploadResponse } from "@lifetracker/shared/types/uploads";
import { assets, AssetTypes } from "@lifetracker/db/schema";
import {
newAssetId,
saveAsset,
SUPPORTED_UPLOAD_ASSET_TYPES,
} from "@lifetracker/shared/assetdb";
import serverConfig from "@lifetracker/shared/config";
const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024;
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (serverConfig.demoMode) {
throw new TRPCError({
message: "Mutations are not allowed in demo mode",
code: "FORBIDDEN",
});
}
const formData = await request.formData();
const data = formData.get("file") ?? formData.get("image");
let buffer;
let contentType;
if (data instanceof File) {
contentType = data.type;
if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) {
return Response.json(
{ error: "Unsupported asset type" },
{ status: 400 },
);
}
if (data.size > MAX_UPLOAD_SIZE_BYTES) {
return Response.json({ error: "Asset is too big" }, { status: 413 });
}
buffer = Buffer.from(await data.arrayBuffer());
} else {
return Response.json({ error: "Bad request" }, { status: 400 });
}
const fileName = data.name;
const [assetDb] = await ctx.db
.insert(assets)
.values({
id: newAssetId(),
// Initially, uploads are uploaded for unknown purpose
// And without an attached bookmark.
assetType: AssetTypes.UNKNOWN,
bookmarkId: null,
userId: ctx.user.id,
contentType,
size: data.size,
fileName,
})
.returning();
const assetId = assetDb.id;
await saveAsset({
userId: ctx.user.id,
assetId,
metadata: { contentType, fileName },
asset: buffer,
});
return Response.json({
assetId,
contentType,
size: buffer.byteLength,
fileName,
} satisfies ZUploadResponse);
}

View File

@ -0,0 +1,3 @@
import { authHandler } from "@/server/auth";
export { authHandler as GET, authHandler as POST };

View File

@ -0,0 +1,40 @@
import { toExportFormat, zExportSchema } from "@/lib/exportBookmarks";
import { api, createContextFromRequest } from "@/server/api/client";
import { z } from "zod";
import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@hoarder/shared/types/bookmarks";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const req = {
limit: MAX_NUM_BOOKMARKS_PER_PAGE,
useCursorV2: true,
};
let resp = await api.bookmarks.getBookmarks(req);
let results = resp.bookmarks.map(toExportFormat);
while (resp.nextCursor) {
resp = await api.bookmarks.getBookmarks({
...request,
cursor: resp.nextCursor,
});
results = [...results, ...resp.bookmarks.map(toExportFormat)];
}
const exportData: z.infer<typeof zExportSchema> = {
bookmarks: results.filter((b) => b.content !== null),
};
return new Response(JSON.stringify(exportData), {
status: 200,
headers: {
"Content-type": "application/json",
"Content-disposition": `attachment; filename="hoarder-export-${new Date().toISOString()}.json"`,
},
});
}

View File

@ -0,0 +1,21 @@
import { createContextFromRequest } from "@/server/api/client";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@lifetracker/trpc/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
onError: ({ path, error }) => {
if (process.env.NODE_ENV === "development") {
console.error(`❌ tRPC failed on ${path}`);
}
console.error(error);
},
createContext: async (opts) => {
return await createContextFromRequest(opts.req);
},
});
export { handler as GET, handler as POST };

View File

@ -1,22 +0,0 @@
import { createContextFromRequest } from "@/server/api/client";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@lifetracker/trpc/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
onError: ({ path, error }) => {
if (process.env.NODE_ENV === "development") {
console.error(`❌ tRPC failed on ${path}`);
}
console.error(error);
},
createContext: async (opts) => {
return await createContextFromRequest(opts.req);
},
});
export { handler as GET, handler as POST };

View File

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
params: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const resp = await api.lists.getListsOfBookmark({
bookmarkId: params.params.bookmarkId,
});
return { status: 200, resp };
},
});

View File

@ -0,0 +1,50 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { zUpdateBookmarksRequestSchema } from "@hoarder/shared/types/bookmarks";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const bookmark = await api.bookmarks.getBookmark({
bookmarkId: params.bookmarkId,
});
return { status: 200, resp: bookmark };
},
});
export const PATCH = (
req: NextRequest,
{ params }: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
bodySchema: zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }),
handler: async ({ api, body }) => {
const bookmark = await api.bookmarks.updateBookmark({
bookmarkId: params.bookmarkId,
...body!,
});
return { status: 200, resp: bookmark };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.bookmarks.deleteBookmark({
bookmarkId: params.bookmarkId,
});
return { status: 204 };
},
});

View File

@ -0,0 +1,45 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { z } from "zod";
import { zManipulatedTagSchema } from "@hoarder/shared/types/bookmarks";
export const dynamic = "force-dynamic";
export const POST = (
req: NextRequest,
params: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
bodySchema: z.object({
tags: z.array(zManipulatedTagSchema),
}),
handler: async ({ api, body }) => {
const resp = await api.bookmarks.updateTags({
bookmarkId: params.params.bookmarkId,
attach: body!.tags,
detach: [],
});
return { status: 200, resp: { attached: resp.attached } };
},
});
export const DELETE = (
req: NextRequest,
params: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
bodySchema: z.object({
tags: z.array(zManipulatedTagSchema),
}),
handler: async ({ api, body }) => {
const resp = await api.bookmarks.updateTags({
bookmarkId: params.params.bookmarkId,
detach: body!.tags,
attach: [],
});
return { status: 200, resp: { detached: resp.detached } };
},
});

View File

@ -0,0 +1,37 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { zNewBookmarkRequestSchema } from "@hoarder/shared/types/bookmarks";
import { buildHandler } from "../utils/handler";
import { adaptPagination, zPagination } from "../utils/pagination";
import { zStringBool } from "../utils/types";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest) =>
buildHandler({
req,
searchParamsSchema: z
.object({
favourited: zStringBool.optional(),
archived: zStringBool.optional(),
})
.and(zPagination),
handler: async ({ api, searchParams }) => {
const bookmarks = await api.bookmarks.getBookmarks({
...searchParams,
});
return { status: 200, resp: adaptPagination(bookmarks) };
},
});
export const POST = (req: NextRequest) =>
buildHandler({
req,
bodySchema: zNewBookmarkRequestSchema,
handler: async ({ api, body }) => {
const bookmark = await api.bookmarks.createBookmark(body!);
return { status: 201, resp: bookmark };
},
});

View File

@ -0,0 +1,35 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
export const dynamic = "force-dynamic";
export const PUT = (
req: NextRequest,
{ params }: { params: { listId: string; bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
// TODO: PUT is supposed to be idempotent, but we currently fail if the bookmark is already in the list.
await api.lists.addToList({
listId: params.listId,
bookmarkId: params.bookmarkId,
});
return { status: 204 };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { listId: string; bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.lists.removeFromList({
listId: params.listId,
bookmarkId: params.bookmarkId,
});
return { status: 204 };
},
});

View File

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest, params: { params: { listId: string } }) =>
buildHandler({
req,
searchParamsSchema: zPagination,
handler: async ({ api, searchParams }) => {
const bookmarks = await api.bookmarks.getBookmarks({
listId: params.params.listId,
...searchParams,
});
return { status: 200, resp: adaptPagination(bookmarks) };
},
});

View File

@ -0,0 +1,55 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { listId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const list = await api.lists.get({
listId: params.listId,
});
return {
status: 200,
resp: list,
};
},
});
export const PATCH = (
req: NextRequest,
{ params }: { params: { listId: string } },
) =>
buildHandler({
req,
bodySchema: zNewBookmarkListSchema.partial(),
handler: async ({ api, body }) => {
const list = await api.lists.edit({
listId: params.listId,
...body!,
});
return { status: 200, resp: list };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { listId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.lists.delete({
listId: params.listId,
});
return {
status: 204,
};
},
});

View File

@ -0,0 +1,26 @@
import { NextRequest } from "next/server";
import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists";
import { buildHandler } from "../utils/handler";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest) =>
buildHandler({
req,
handler: async ({ api }) => {
const lists = await api.lists.list();
return { status: 200, resp: lists };
},
});
export const POST = (req: NextRequest) =>
buildHandler({
req,
bodySchema: zNewBookmarkListSchema,
handler: async ({ api, body }) => {
const list = await api.lists.create(body!);
return { status: 201, resp: list };
},
});

View File

@ -0,0 +1,25 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
searchParamsSchema: zPagination,
handler: async ({ api, searchParams }) => {
const bookmarks = await api.bookmarks.getBookmarks({
tagId: params.tagId,
limit: searchParams.limit,
cursor: searchParams.cursor,
});
return {
status: 200,
resp: adaptPagination(bookmarks),
};
},
});

View File

@ -0,0 +1,55 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { zUpdateTagRequestSchema } from "@hoarder/shared/types/tags";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const tag = await api.tags.get({
tagId: params.tagId,
});
return {
status: 200,
resp: tag,
};
},
});
export const PATCH = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
bodySchema: zUpdateTagRequestSchema.omit({ tagId: true }),
handler: async ({ api, body }) => {
const tag = await api.tags.update({
tagId: params.tagId,
...body!,
});
return { status: 200, resp: tag };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.tags.delete({
tagId: params.tagId,
});
return {
status: 204,
};
},
});

View File

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { buildHandler } from "../utils/handler";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest) =>
buildHandler({
req,
handler: async ({ api }) => {
const tags = await api.tags.list();
return { status: 200, resp: tags };
},
});

View File

@ -0,0 +1,170 @@
import { NextRequest } from "next/server";
import {
createContextFromRequest,
createTrcpClientFromCtx,
} from "@/server/api/client";
import { TRPCError } from "@trpc/server";
import { z, ZodError } from "zod";
import { Context } from "@lifetracker/trpc";
function trpcCodeToHttpCode(code: TRPCError["code"]) {
switch (code) {
case "BAD_REQUEST":
case "PARSE_ERROR":
return 400;
case "UNAUTHORIZED":
return 401;
case "FORBIDDEN":
return 403;
case "NOT_FOUND":
return 404;
case "METHOD_NOT_SUPPORTED":
return 405;
case "TIMEOUT":
return 408;
case "PAYLOAD_TOO_LARGE":
return 413;
case "INTERNAL_SERVER_ERROR":
return 500;
default:
return 500;
}
}
interface ErrorMessage {
path: (string | number)[];
message: string;
}
function formatZodError(error: ZodError): string {
if (!error.issues) {
return error.message || "An unknown error occurred";
}
const errors: ErrorMessage[] = error.issues.map((issue) => ({
path: issue.path,
message: issue.message,
}));
const formattedErrors = errors.map((err) => {
const path = err.path.join(".");
return path ? `${path}: ${err.message}` : err.message;
});
return `${formattedErrors.join(", ")}`;
}
export interface TrpcAPIRequest<SearchParamsT, BodyType> {
ctx: Context;
api: ReturnType<typeof createTrcpClientFromCtx>;
searchParams: SearchParamsT extends z.ZodTypeAny
? z.infer<SearchParamsT>
: undefined;
body: BodyType extends z.ZodTypeAny
? z.infer<BodyType> | undefined
: undefined;
}
type SchemaType<T> = T extends z.ZodTypeAny
? z.infer<T> | undefined
: undefined;
export async function buildHandler<
SearchParamsT extends z.ZodTypeAny | undefined,
BodyT extends z.ZodTypeAny | undefined,
InputT extends TrpcAPIRequest<SearchParamsT, BodyT>,
>({
req,
handler,
searchParamsSchema,
bodySchema,
}: {
req: NextRequest;
handler: (req: InputT) => Promise<{ status: number; resp?: object }>;
searchParamsSchema?: SearchParamsT | undefined;
bodySchema?: BodyT | undefined;
}) {
try {
const ctx = await createContextFromRequest(req);
const api = createTrcpClientFromCtx(ctx);
let searchParams: SchemaType<SearchParamsT> | undefined = undefined;
if (searchParamsSchema !== undefined) {
searchParams = searchParamsSchema.parse(
Object.fromEntries(req.nextUrl.searchParams.entries()),
) as SchemaType<SearchParamsT>;
}
let body: SchemaType<BodyT> | undefined = undefined;
if (bodySchema) {
if (req.headers.get("Content-Type") !== "application/json") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Content-Type must be application/json",
});
}
let bodyJson = undefined;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
bodyJson = await req.json();
} catch (e) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid JSON: ${(e as Error).message}`,
});
}
body = bodySchema.parse(bodyJson) as SchemaType<BodyT>;
}
const { status, resp } = await handler({
ctx,
api,
searchParams,
body,
} as InputT);
return new Response(resp ? JSON.stringify(resp) : null, {
status,
headers: {
"Content-Type": "application/json",
},
});
} catch (e) {
if (e instanceof ZodError) {
return new Response(
JSON.stringify({ code: "ParseError", message: formatZodError(e) }),
{
status: 400,
headers: {
"Content-Type": "application/json",
},
},
);
}
if (e instanceof TRPCError) {
let message = e.message;
if (e.cause instanceof ZodError) {
message = formatZodError(e.cause);
}
return new Response(JSON.stringify({ code: e.code, error: message }), {
status: trpcCodeToHttpCode(e.code),
headers: {
"Content-Type": "application/json",
},
});
} else {
const error = e as Error;
console.error(
`Unexpected error in: ${req.method} ${req.nextUrl.pathname}:\n${error.stack}`,
);
return new Response(JSON.stringify({ code: "UnknownError" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}
}

View File

@ -0,0 +1,32 @@
import { z } from "zod";
import {
MAX_NUM_BOOKMARKS_PER_PAGE,
zCursorV2,
} from "@hoarder/shared/types/bookmarks";
export const zPagination = z.object({
limit: z.coerce.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
cursor: z
.string()
.refine((val) => val.includes("_"), "Must be a valid cursor")
.transform((val) => {
const [id, createdAt] = val.split("_");
return { id, createdAt };
})
.pipe(z.object({ id: z.string(), createdAt: z.coerce.date() }))
.optional(),
});
export function adaptPagination<
T extends { nextCursor: z.infer<typeof zCursorV2> | null },
>(input: T) {
const { nextCursor, ...rest } = input;
if (!nextCursor) {
return input;
}
return {
...rest,
nextCursor: `${nextCursor.id}_${nextCursor.createdAt.toISOString()}`,
};
}

View File

@ -0,0 +1,6 @@
import { z } from "zod";
export const zStringBool = z
.string()
.refine((val) => val === "true" || val === "false", "Must be true or false")
.transform((val) => val === "true");

BIN
apps/web/app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import BookmarkPreview from "@/components/dashboard/preview/BookmarkPreview";
import { Dialog, DialogContent } from "@/components/ui/dialog";
export default function BookmarkPreviewPage({
params,
}: {
params: { bookmarkId: string };
}) {
const router = useRouter();
const [open, setOpen] = useState(true);
const setOpenWithRouter = (value: boolean) => {
setOpen(value);
if (!value) {
router.back();
}
};
return (
<Dialog open={open} onOpenChange={setOpenWithRouter}>
<DialogContent
className="h-[90%] max-w-[90%] overflow-hidden p-0"
hideCloseBtn={true}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<BookmarkPreview bookmarkId={params.bookmarkId} />
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,3 @@
export default function CatchAll() {
return null;
}

View File

@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import AdminActions from "@/components/dashboard/admin/AdminActions";
import ServerStats from "@/components/dashboard/admin/ServerStats";
import UserList from "@/components/dashboard/admin/UserList";
import { getServerAuthSession } from "@/server/auth";
export default async function AdminPage() {
const session = await getServerAuthSession();
if (!session || session.user.role !== "admin") {
redirect("/");
}
return (
<>
<div className="rounded-md border bg-background p-4">
<ServerStats />
<AdminActions />
</div>
<div className="mt-4 rounded-md border bg-background p-4">
<UserList />
</div>
</>
);
}

View File

@ -0,0 +1,24 @@
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import InfoTooltip from "@/components/ui/info-tooltip";
function header() {
return (
<div className="flex gap-2">
<p className="text-2xl">🗄 Archive</p>
<InfoTooltip size={17} className="my-auto" variant="explain">
<p>Archived bookmarks won&apos;t appear in the homepage</p>
</InfoTooltip>
</div>
);
}
export default async function ArchivedBookmarkPage() {
return (
<Bookmarks
header={header()}
query={{ archived: true }}
showDivider={true}
showEditorCard={true}
/>
);
}

View File

@ -0,0 +1,10 @@
import React from "react";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
export default async function BookmarksPage() {
return (
<div>
<Bookmarks query={{ archived: false }} showEditorCard={true} />
</div>
);
}

View File

@ -0,0 +1,21 @@
import { TagDuplicationDetection } from "@/components/dashboard/cleanups/TagDuplicationDetention";
import { Separator } from "@/components/ui/separator";
import { Paintbrush, Tags } from "lucide-react";
export default function Cleanups() {
return (
<div className="flex flex-col gap-y-4 rounded-md border bg-background p-4">
<span className="flex items-center gap-1 text-2xl">
<Paintbrush />
Cleanups
</span>
<Separator />
<span className="flex items-center gap-1 text-xl">
<Tags />
Duplicate Tags
</span>
<Separator />
<TagDuplicationDetection />
</div>
);
}

View File

@ -0,0 +1,9 @@
"use client";
export default function Error() {
return (
<div className="flex size-full">
<div className="m-auto text-3xl">Something went wrong</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
export default async function FavouritesBookmarkPage() {
return (
<Bookmarks
header={
<div className="flex items-center justify-between">
<p className="text-2xl"> Favourites</p>
</div>
}
query={{ favourited: true }}
showDivider={true}
showEditorCard={true}
/>
);
}

View File

@ -0,0 +1,31 @@
import { notFound } from "next/navigation";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
export default async function FeedPage({
params,
}: {
params: { feedId: string };
}) {
let feed;
try {
feed = await api.feeds.get({ feedId: params.feedId });
} catch (e) {
if (e instanceof TRPCError) {
if (e.code == "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<Bookmarks
query={{ rssFeedId: feed.id }}
showDivider={true}
showEditorCard={false}
header={<div className="text-2xl">{feed.name}</div>}
/>
);
}

View File

@ -0,0 +1,37 @@
import Header from "@/components/dashboard/header/Header";
import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar";
import Sidebar from "@/components/dashboard/sidebar/Sidebar";
import DemoModeBanner from "@/components/DemoModeBanner";
import { Separator } from "@/components/ui/separator";
import ValidAccountCheck from "@/components/utils/ValidAccountCheck";
import serverConfig from "@lifetracker/shared/config";
export default async function Dashboard({
children,
modal,
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode;
}>) {
return (
<div>
<Header />
<div className="flex min-h-[calc(100vh-64px)] w-screen flex-col sm:h-[calc(100vh-64px)] sm:flex-row">
<ValidAccountCheck />
<div className="hidden flex-none sm:flex">
<Sidebar />
</div>
<main className="flex-1 bg-muted sm:overflow-y-auto">
{serverConfig.demoMode && <DemoModeBanner />}
<div className="block w-full sm:hidden">
<MobileSidebar />
<Separator />
</div>
{modal}
<div className="min-h-30 container p-4">{children}</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { notFound } from "next/navigation";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import ListHeader from "@/components/dashboard/lists/ListHeader";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
export default async function ListPage({
params,
}: {
params: { listId: string };
}) {
let list;
try {
list = await api.lists.get({ listId: params.listId });
} catch (e) {
if (e instanceof TRPCError) {
if (e.code == "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<Bookmarks
query={{ listId: list.id }}
showDivider={true}
showEditorCard={true}
header={<ListHeader initialData={list} />}
/>
);
}

View File

@ -0,0 +1,15 @@
import AllListsView from "@/components/dashboard/lists/AllListsView";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
export default async function ListsPage() {
const lists = await api.lists.list();
return (
<div className="flex flex-col gap-3 rounded-md border bg-background p-4">
<p className="text-2xl">📋 All Lists</p>
<Separator />
<AllListsView initialData={lists.lists} />
</div>
);
}

View File

@ -0,0 +1,7 @@
export default function NotFound() {
return (
<div className="flex size-full">
<div className="m-auto text-3xl">Not Found :(</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { notFound } from "next/navigation";
import BookmarkPreview from "@/components/dashboard/preview/BookmarkPreview";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
export default async function BookmarkPreviewPage({
params,
}: {
params: { bookmarkId: string };
}) {
let bookmark;
try {
bookmark = await api.bookmarks.getBookmark({
bookmarkId: params.bookmarkId,
});
} catch (e) {
if (e instanceof TRPCError) {
if (e.code === "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<div className="max-h-screen">
<BookmarkPreview bookmarkId={bookmark.id} initialData={bookmark} />
</div>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { Suspense } from "react";
import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { useBookmarkSearch } from "@/lib/hooks/bookmark-search";
function SearchComp() {
const { data } = useBookmarkSearch();
return (
<div className="flex flex-col gap-3">
{data ? (
<BookmarksGrid bookmarks={data.bookmarks} />
) : (
<FullPageSpinner />
)}
</div>
);
}
export default function SearchPage() {
return (
<Suspense>
<SearchComp />
</Suspense>
);
}

View File

@ -0,0 +1,47 @@
import { notFound } from "next/navigation";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import EditableTagName from "@/components/dashboard/tags/EditableTagName";
import { TagOptions } from "@/components/dashboard/tags/TagOptions";
import { Button } from "@/components/ui/button";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
import { MoreHorizontal } from "lucide-react";
export default async function TagPage({
params,
}: {
params: { tagId: string };
}) {
let tag;
try {
tag = await api.tags.get({ tagId: params.tagId });
} catch (e) {
if (e instanceof TRPCError) {
if (e.code == "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<Bookmarks
header={
<div className="flex justify-between">
<EditableTagName
tag={{ id: tag.id, name: tag.name }}
className="text-2xl"
/>
<TagOptions tag={tag}>
<Button variant="ghost">
<MoreHorizontal />
</Button>
</TagOptions>
</div>
}
query={{ tagId: tag.id }}
showEditorCard={true}
/>
);
}

View File

@ -0,0 +1,15 @@
import AllTagsView from "@/components/dashboard/tags/AllTagsView";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
export default async function TagsPage() {
const allTags = (await api.tags.list()).tags;
return (
<div className="space-y-4 rounded-md border bg-background p-4">
<span className="text-2xl">All Tags</span>
<Separator />
<AllTagsView initialData={allTags} />
</div>
);
}

View File

@ -0,0 +1,9 @@
import React from "react";
export default async function TodayPage() {
return (
<div>
Hello from a logged in page!
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,39 +0,0 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

BIN
apps/web/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,30 +1,72 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Inter } from "next/font/google";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
import "@lifetracker/tailwind-config/globals.css";
import type { Viewport } from "next";
import React from "react";
import { cookies } from "next/headers";
import { Toaster } from "@/components/ui/toaster";
import Providers from "@/lib/providers";
import {
defaultUserLocalSettings,
parseUserLocalSettings,
USER_LOCAL_SETTINGS_COOKIE_NAME,
} from "@/lib/userLocalSettings/types";
import { getServerAuthSession } from "@/server/auth";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { clientConfig } from "@lifetracker/shared/config";
const inter = Inter({
subsets: ["latin"],
fallback: ["sans-serif"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Lifetracker",
applicationName: "Lifetracker",
description:
"The all-in-one life tracking app.",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
title: "LifeTracker",
},
formatDetection: {
telephone: false,
},
};
export default function RootLayout({
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await getServerAuthSession();
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<body className={inter.className}>
<Providers
session={session}
clientConfig={clientConfig}
userLocalSettings={
parseUserLocalSettings(
(await cookies()).get(USER_LOCAL_SETTINGS_COOKIE_NAME)?.value
) ?? defaultUserLocalSettings()
}
>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</Providers>
<Toaster />
</body>
</html>
);

View File

@ -1,188 +0,0 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-synthesis: none;
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: none;
font-family: var(--font-geist-sans);
border: 1px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 180px;
}
button.secondary {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: none;
font-family: var(--font-geist-sans);
border: 1px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
background: transparent;
border-color: var(--gray-alpha-200);
min-width: 180px;
}
.footer {
font-family: var(--font-geist-sans);
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}

View File

@ -1,99 +1,11 @@
import Image from "next/image";
import { Button } from "@lifetracker/ui/button";
import styles from "./page.module.css";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "@/server/auth";
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>app/page.tsx</code>
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
>
Read our docs
</a>
</div>
<Button appName="web" className={styles.secondary}>
Open alert
</Button>
</main>
<footer className={styles.footer}>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file-text.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
export default async function Home() {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard/settings");
} else {
redirect("/signin");
}
}

View File

@ -1,60 +0,0 @@
import { headers } from "next/headers";
import { getServerAuthSession } from "@/server/auth";
import requestIp from "request-ip";
import { db } from "@lifetracker/db";
import { Context, createCallerFactory } from "@lifetracker/trpc";
import { authenticateApiKey } from "@lifetracker/trpc/auth";
import { appRouter } from "@lifetracker/trpc/routers/_app";
export async function createContextFromRequest(req: Request) {
// TODO: This is a hack until we offer a proper REST API instead of the trpc based one.
// Check if the request has an Authorization token, if it does, assume that API key authentication is requested.
const ip = requestIp.getClientIp({
headers: Object.fromEntries(req.headers.entries()),
});
const authorizationHeader = req.headers.get("Authorization");
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
const token = authorizationHeader.split(" ")[1];
try {
const user = await authenticateApiKey(token);
return {
user,
db,
req: {
ip,
},
};
} catch (e) {
// Fallthrough to cookie-based auth
}
}
return createContext(db, ip);
}
export const createContext = async (
database?: typeof db,
ip?: string | null,
): Promise<Context> => {
const session = await getServerAuthSession();
if (ip === undefined) {
const hdrs = headers();
ip = requestIp.getClientIp({
headers: Object.fromEntries(hdrs.entries()),
});
}
return {
user: session?.user ?? null,
db: database ?? db,
req: {
ip,
},
};
};
const createCaller = createCallerFactory(appRouter);
export const api = createCaller(createContext);
export const createTrcpClientFromCtx = createCaller;

View File

@ -0,0 +1,5 @@
import AISettings from "@/components/settings/AISettings";
export default function AISettingsPage() {
return <AISettings />;
}

View File

@ -0,0 +1,9 @@
import ApiKeySettings from "@/components/settings/ApiKeySettings";
export default async function ApiKeysPage() {
return (
<div className="rounded-md border bg-background p-4">
<ApiKeySettings />
</div>
);
}

View File

@ -0,0 +1,5 @@
import FeedSettings from "@/components/settings/FeedSettings";
export default function FeedSettingsPage() {
return <FeedSettings />;
}

View File

@ -0,0 +1,9 @@
import ImportExport from "@/components/settings/ImportExport";
export default function ImportSettingsPage() {
return (
<div className="rounded-md border bg-background p-4">
<ImportExport />
</div>
);
}

View File

@ -0,0 +1,11 @@
import { ChangePassword } from "@/components/settings/ChangePassword";
import UserDetails from "@/components/settings/UserDetails";
export default async function InfoPage() {
return (
<div className="rounded-md border bg-background p-4">
<UserDetails />
<ChangePassword />
</div>
);
}

View File

@ -0,0 +1,34 @@
import Header from "@/components/dashboard/header/Header";
import DemoModeBanner from "@/components/DemoModeBanner";
import MobileSidebar from "@/components/settings/sidebar/ModileSidebar";
import Sidebar from "@/components/settings/sidebar/Sidebar";
import { Separator } from "@/components/ui/separator";
import ValidAccountCheck from "@/components/utils/ValidAccountCheck";
import serverConfig from "@lifetracker/shared/config";
export default async function SettingsLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div>
<Header />
<div className="flex min-h-[calc(100vh-64px)] w-screen flex-col sm:h-[calc(100vh-64px)] sm:flex-row">
<ValidAccountCheck />
<div className="hidden flex-none sm:flex">
<Sidebar />
</div>
<main className="flex-1 bg-muted sm:overflow-y-auto">
{serverConfig.demoMode && <DemoModeBanner />}
<div className="block w-full sm:hidden">
<MobileSidebar />
<Separator />
</div>
<div className="min-h-30 container p-4">{children}</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function SettingsHomepage() {
redirect("/settings/info");
return null;
}

View File

@ -0,0 +1,22 @@
import { redirect } from "next/dist/client/components/navigation";
import HoarderLogo from "@/components/HoarderIcon";
import SignInForm from "@/components/signin/SignInForm";
import { getServerAuthSession } from "@/server/auth";
export default async function SignInPage() {
const session = await getServerAuthSession();
if (session) {
redirect("/");
}
return (
<div className="grid min-h-screen grid-rows-6 justify-center">
<div className="row-span-2 flex w-96 items-center justify-center space-x-2">
<HoarderLogo height={62} gap="12px" />
</div>
<div className="row-span-4 px-3">
<SignInForm />
</div>
</div>
);
}

View File

17
apps/web/components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@ -0,0 +1,7 @@
export default function DemoModeBanner() {
return (
<div className="h-min w-full rounded bg-yellow-100 px-4 py-2 text-center text-black">
Demo mode is on. All modifications are disabled.
</div>
);
}

View File

@ -0,0 +1,17 @@
export default function LifetrackerLogo({
height,
gap,
}: {
height: number;
gap: string;
}) {
return (
<span style={{ gap }} className="flex items-center">
<span
style={{ fontSize: "50px" }}
className={`fill-foreground`}
>Lifetracker</span>
</span>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import React from "react";
import { ButtonWithTooltip } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useBookmarkLayout } from "@/lib/userLocalSettings/bookmarksLayout";
import { updateBookmarksLayout } from "@/lib/userLocalSettings/userLocalSettings";
import {
Check,
LayoutDashboard,
LayoutGrid,
LayoutList,
List,
} from "lucide-react";
type LayoutType = "masonry" | "grid" | "list";
const iconMap = {
masonry: LayoutDashboard,
grid: LayoutGrid,
list: LayoutList,
compact: List,
};
export default function ChangeLayout() {
const layout = useBookmarkLayout();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonWithTooltip
tooltip="Change layout"
delayDuration={100}
variant="ghost"
>
{React.createElement(iconMap[layout], { size: 18 })}
</ButtonWithTooltip>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit">
{Object.keys(iconMap).map((key) => (
<DropdownMenuItem
key={key}
className="cursor-pointer justify-between"
onClick={async () => await updateBookmarksLayout(key as LayoutType)}
>
<div className="flex items-center gap-2">
{React.createElement(iconMap[key as LayoutType], { size: 18 })}
<span className="capitalize">{key}</span>
</div>
{layout == key && <Check className="ml-2 size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import { ActionButtonWithTooltip } from "@/components/ui/action-button";
import { ButtonWithTooltip } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Check, Pencil, X } from "lucide-react";
interface Props {
viewClassName?: string;
untitledClassName?: string;
editClassName?: string;
onSave: (title: string | null) => void;
isSaving: boolean;
originalText: string | null;
setEditable: (editable: boolean) => void;
}
function EditMode({
onSave: onSaveCB,
editClassName: className,
isSaving,
originalText,
setEditable,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.focus();
ref.current.textContent = originalText;
}
}, [ref]);
const onSave = () => {
let toSave: string | null = ref.current?.textContent ?? null;
if (originalText == toSave) {
// Nothing to do here
return;
}
if (toSave == "") {
toSave = null;
}
onSaveCB(toSave);
setEditable(false);
};
return (
<div className="flex gap-3">
<div
ref={ref}
role="presentation"
className={className}
contentEditable={true}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSave();
}
}}
/>
<ActionButtonWithTooltip
tooltip="Save"
delayDuration={500}
size="none"
variant="ghost"
className="align-middle text-gray-400"
loading={isSaving}
onClick={() => onSave()}
>
<Check className="size-4" />
</ActionButtonWithTooltip>
<ButtonWithTooltip
tooltip="Cancel"
delayDuration={500}
size="none"
variant="ghost"
className="align-middle text-gray-400"
onClick={() => {
setEditable(false);
}}
>
<X className="size-4" />
</ButtonWithTooltip>
</div>
);
}
function ViewMode({
originalText,
setEditable,
viewClassName,
untitledClassName,
}: Props) {
return (
<Tooltip delayDuration={500}>
<div className="flex max-w-full items-center gap-3">
<TooltipTrigger asChild>
{originalText ? (
<p className={viewClassName}>{originalText}</p>
) : (
<p className={untitledClassName}>Untitled</p>
)}
</TooltipTrigger>
<ButtonWithTooltip
delayDuration={500}
tooltip="Edit title"
size="none"
variant="ghost"
className="align-middle text-gray-400"
onClick={() => {
setEditable(true);
}}
>
<Pencil className="size-4" />
</ButtonWithTooltip>
</div>
<TooltipPortal>
{originalText && (
<TooltipContent side="bottom" className="max-w-[40ch] break-words">
{originalText}
</TooltipContent>
)}
</TooltipPortal>
</Tooltip>
);
}
export function EditableText(props: {
viewClassName?: string;
untitledClassName?: string;
editClassName?: string;
originalText: string | null;
onSave: (title: string | null) => void;
isSaving: boolean;
}) {
const [editable, setEditable] = useState(false);
return editable ? (
<EditMode setEditable={setEditable} {...props} />
) : (
<ViewMode setEditable={setEditable} {...props} />
);
}

View File

@ -0,0 +1,11 @@
"use client";
import ChangeLayout from "@/components/dashboard/ChangeLayout";
export default function GlobalActions() {
return (
<div className="flex min-w-max flex-wrap overflow-hidden">
<ChangeLayout />
</div>
);
}

View File

@ -0,0 +1,146 @@
"use client";
import React, { useCallback, useState } from "react";
import useUpload from "@/lib/hooks/upload-file";
import { cn } from "@/lib/utils";
import { TRPCClientError } from "@trpc/client";
import DropZone from "react-dropzone";
import { useCreateBookmarkWithPostHook } from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import LoadingSpinner from "../ui/spinner";
import { toast } from "../ui/use-toast";
import BookmarkAlreadyExistsToast from "../utils/BookmarkAlreadyExistsToast";
export function useUploadAsset() {
const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook({
onSuccess: (resp) => {
if (resp.alreadyExists) {
toast({
description: <BookmarkAlreadyExistsToast bookmarkId={resp.id} />,
variant: "default",
});
} else {
toast({ description: "Bookmark uploaded" });
}
},
onError: () => {
toast({ description: "Something went wrong", variant: "destructive" });
},
});
const { mutateAsync: runUploadAsset } = useUpload({
onSuccess: async (resp) => {
const assetType =
resp.contentType === "application/pdf" ? "pdf" : "image";
await createBookmark({ ...resp, type: BookmarkTypes.ASSET, assetType });
},
onError: (err, req) => {
toast({
description: `${req.name}: ${err.error}`,
variant: "destructive",
});
},
});
return useCallback(
(file: File) => {
return runUploadAsset(file);
},
[runUploadAsset],
);
}
function useUploadAssets({
onFileUpload,
onFileError,
onAllUploaded,
}: {
onFileUpload: () => void;
onFileError: (name: string, e: Error) => void;
onAllUploaded: () => void;
}) {
const runUpload = useUploadAsset();
return async (files: File[]) => {
if (files.length == 0) {
return;
}
for (const file of files) {
try {
await runUpload(file);
onFileUpload();
} catch (e) {
if (e instanceof TRPCClientError || e instanceof Error) {
onFileError(file.name, e);
}
}
}
onAllUploaded();
};
}
export default function UploadDropzone({
children,
}: {
children: React.ReactNode;
}) {
const [numUploading, setNumUploading] = useState(0);
const [numUploaded, setNumUploaded] = useState(0);
const uploadAssets = useUploadAssets({
onFileUpload: () => {
setNumUploaded((c) => c + 1);
},
onFileError: () => {
setNumUploaded((c) => c + 1);
},
onAllUploaded: () => {
setNumUploading(0);
setNumUploaded(0);
return;
},
});
const [isDragging, setDragging] = useState(false);
const onDrop = (acceptedFiles: File[]) => {
uploadAssets(acceptedFiles);
setNumUploading(acceptedFiles.length);
setDragging(false);
};
return (
<DropZone
noClick
onDrop={onDrop}
onDragEnter={() => setDragging(true)}
onDragLeave={() => setDragging(false)}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} hidden />
<div
className={cn(
"fixed inset-0 z-50 flex h-full w-full items-center justify-center bg-gray-200 opacity-90",
isDragging || numUploading > 0 ? undefined : "hidden",
)}
>
{numUploading > 0 ? (
<div className="flex items-center justify-center gap-2">
<p className="text-2xl font-bold text-gray-700">
Uploading {numUploaded} / {numUploading}
</p>
<LoadingSpinner />
</div>
) : (
<p className="text-2xl font-bold text-gray-700">
Drop Your Image / Bookmark file
</p>
)}
</div>
{children}
</div>
)}
</DropZone>
);
}

View File

@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zAdminCreateUserSchema } from "@hoarder/shared/types/admin";
type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>;
export default function AddUserDialog({
children,
}: {
children?: React.ReactNode;
}) {
const apiUtils = api.useUtils();
const [isOpen, onOpenChange] = useState(false);
const form = useForm<AdminCreateUserSchema>({
resolver: zodResolver(zAdminCreateUserSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
role: "user",
},
});
const { mutate, isPending } = api.admin.createUser.useMutation({
onSuccess: () => {
toast({
description: "User created successfully",
});
onOpenChange(false);
apiUtils.users.list.invalidate();
apiUtils.admin.userStats.invalidate();
},
onError: (error) => {
if (error instanceof TRPCClientError) {
toast({
variant: "destructive",
description: error.message,
});
} else {
toast({
variant: "destructive",
description: "Failed to create user",
});
}
},
});
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((val) => mutate(val))}>
<div className="flex w-full flex-col space-y-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Name"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Email"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton
type="submit"
loading={isPending}
disabled={isPending}
>
Create
</ActionButton>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,134 @@
"use client";
import { ActionButton } from "@/components/ui/action-button";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
export default function AdminActions() {
const { mutate: recrawlLinks, isPending: isRecrawlPending } =
api.admin.recrawlLinks.useMutation({
onSuccess: () => {
toast({
description: "Recrawl enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const { mutate: reindexBookmarks, isPending: isReindexPending } =
api.admin.reindexAllBookmarks.useMutation({
onSuccess: () => {
toast({
description: "Reindex enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const {
mutate: reRunInferenceOnAllBookmarks,
isPending: isInferencePending,
} = api.admin.reRunInferenceOnAllBookmarks.useMutation({
onSuccess: () => {
toast({
description: "Inference jobs enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const { mutateAsync: tidyAssets, isPending: isTidyAssetsPending } =
api.admin.tidyAssets.useMutation({
onSuccess: () => {
toast({
description: "Tidy assets request has been enqueued!",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
return (
<div>
<div className="mb-2 mt-8 text-xl font-medium">Actions</div>
<div className="flex flex-col gap-2 sm:w-1/2">
<ActionButton
variant="destructive"
loading={isRecrawlPending}
onClick={() =>
recrawlLinks({ crawlStatus: "failure", runInference: true })
}
>
Recrawl Failed Links Only
</ActionButton>
<ActionButton
variant="destructive"
loading={isRecrawlPending}
onClick={() =>
recrawlLinks({ crawlStatus: "all", runInference: true })
}
>
Recrawl All Links
</ActionButton>
<ActionButton
variant="destructive"
loading={isRecrawlPending}
onClick={() =>
recrawlLinks({ crawlStatus: "all", runInference: false })
}
>
Recrawl All Links (Without Inference)
</ActionButton>
<ActionButton
variant="destructive"
loading={isInferencePending}
onClick={() =>
reRunInferenceOnAllBookmarks({ taggingStatus: "failure" })
}
>
Regenerate AI Tags for Failed Bookmarks Only
</ActionButton>
<ActionButton
variant="destructive"
loading={isInferencePending}
onClick={() => reRunInferenceOnAllBookmarks({ taggingStatus: "all" })}
>
Regenerate AI Tags for All Bookmarks
</ActionButton>
<ActionButton
variant="destructive"
loading={isReindexPending}
onClick={() => reindexBookmarks()}
>
Reindex All Bookmarks
</ActionButton>
<ActionButton
variant="destructive"
loading={isTidyAssetsPending}
onClick={() => tidyAssets()}
>
Compact Assets
</ActionButton>
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { changeRoleSchema } from "@hoarder/shared/types/admin";
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
interface ChangeRoleDialogProps {
userId: string;
currentRole: "user" | "admin";
children?: React.ReactNode;
}
export default function ChangeRoleDialog({
userId,
currentRole,
children,
}: ChangeRoleDialogProps) {
const apiUtils = api.useUtils();
const [isOpen, onOpenChange] = useState(false);
const form = useForm<ChangeRoleSchema>({
resolver: zodResolver(changeRoleSchema),
defaultValues: {
userId,
role: currentRole,
},
});
const { mutate, isPending } = api.admin.changeRole.useMutation({
onSuccess: () => {
toast({
description: "Role changed successfully",
});
apiUtils.users.list.invalidate();
onOpenChange(false);
},
onError: (error) => {
if (error instanceof TRPCClientError) {
toast({
variant: "destructive",
description: error.message,
});
} else {
toast({
variant: "destructive",
description: "Failed to change role",
});
}
},
});
useEffect(() => {
if (isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogTrigger asChild></DialogTrigger>
<DialogHeader>
<DialogTitle>Change Role</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((val) => mutate(val))}>
<div className="flex w-full flex-col space-y-2">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="userId"
render={({ field }) => (
<FormItem>
<FormControl>
<input type="hidden" {...field} />
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton
type="submit"
loading={isPending}
disabled={isPending}
>
Change
</ActionButton>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc"; // Adjust the import path as needed
import { zodResolver } from "@hookform/resolvers/zod";
import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { resetPasswordSchema } from "@hoarder/shared/types/admin";
interface ResetPasswordDialogProps {
userId: string;
children?: React.ReactNode;
}
type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>;
export default function ResetPasswordDialog({
children,
userId,
}: ResetPasswordDialogProps) {
const [isOpen, onOpenChange] = useState(false);
const form = useForm<ResetPasswordSchema>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: {
userId,
newPassword: "",
newPasswordConfirm: "",
},
});
const { mutate, isPending } = api.admin.resetPassword.useMutation({
onSuccess: () => {
toast({
description: "Password reset successfully",
});
onOpenChange(false);
},
onError: (error) => {
if (error instanceof TRPCClientError) {
toast({
variant: "destructive",
description: error.message,
});
} else {
toast({
variant: "destructive",
description: "Failed to reset password",
});
}
},
});
useEffect(() => {
if (isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((val) => mutate(val))}>
<div className="flex w-full flex-col space-y-2">
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="New Password"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPasswordConfirm"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm New Password"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton
type="submit"
loading={isPending}
disabled={isPending}
>
Reset
</ActionButton>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,136 @@
"use client";
import LoadingSpinner from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
const REPO_LATEST_RELEASE_API =
"https://api.github.com/repos/hoarder-app/hoarder/releases/latest";
const REPO_RELEASE_PAGE = "https://github.com/hoarder-app/hoarder/releases";
function useLatestRelease() {
const { data } = useQuery({
queryKey: ["latest-release"],
queryFn: async () => {
const res = await fetch(REPO_LATEST_RELEASE_API);
if (!res.ok) {
return undefined;
}
const data = (await res.json()) as { name: string };
return data.name;
},
staleTime: 60 * 60 * 1000,
enabled: !useClientConfig().disableNewReleaseCheck,
});
return data;
}
function ReleaseInfo() {
const currentRelease = useClientConfig().serverVersion ?? "NA";
const latestRelease = useLatestRelease();
let newRelease;
if (latestRelease && currentRelease != latestRelease) {
newRelease = (
<a
href={REPO_RELEASE_PAGE}
target="_blank"
className="text-blue-500"
rel="noreferrer"
title="Update available"
>
({latestRelease} )
</a>
);
}
return (
<div className="text-nowrap">
<span className="text-3xl font-semibold">{currentRelease}</span>
<span className="ml-1 text-sm">{newRelease}</span>
</div>
);
}
export default function ServerStats() {
const { data: serverStats } = api.admin.stats.useQuery(undefined, {
refetchInterval: 1000,
placeholderData: keepPreviousData,
});
if (!serverStats) {
return <LoadingSpinner />;
}
return (
<>
<div className="mb-2 text-xl font-medium">Server Stats</div>
<div className="flex flex-col gap-4 sm:flex-row">
<div className="rounded-md border bg-background p-4 sm:w-1/4">
<div className="text-sm font-medium text-gray-400">Total Users</div>
<div className="text-3xl font-semibold">{serverStats.numUsers}</div>
</div>
<div className="rounded-md border bg-background p-4 sm:w-1/4">
<div className="text-sm font-medium text-gray-400">
Total Bookmarks
</div>
<div className="text-3xl font-semibold">
{serverStats.numBookmarks}
</div>
</div>
<div className="rounded-md border bg-background p-4 sm:w-1/4">
<div className="text-sm font-medium text-gray-400">
Server Version
</div>
<ReleaseInfo />
</div>
</div>
<div className="sm:w-1/2">
<div className="mb-2 mt-8 text-xl font-medium">Background Jobs</div>
<Table className="rounded-md border">
<TableHeader className="bg-gray-200">
<TableHead>Job</TableHead>
<TableHead>Queued</TableHead>
<TableHead>Pending</TableHead>
<TableHead>Failed</TableHead>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="lg:w-2/3">Crawling Jobs</TableCell>
<TableCell>{serverStats.crawlStats.queued}</TableCell>
<TableCell>{serverStats.crawlStats.pending}</TableCell>
<TableCell>{serverStats.crawlStats.failed}</TableCell>
</TableRow>
<TableRow>
<TableCell>Indexing Jobs</TableCell>
<TableCell>{serverStats.indexingStats.queued}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell>Inference Jobs</TableCell>
<TableCell>{serverStats.inferenceStats.queued}</TableCell>
<TableCell>{serverStats.inferenceStats.pending}</TableCell>
<TableCell>{serverStats.inferenceStats.failed}</TableCell>
</TableRow>
<TableRow>
<TableCell>Tidy Assets Jobs</TableCell>
<TableCell>{serverStats.tidyAssetsStats.queued}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</>
);
}

View File

@ -0,0 +1,126 @@
"use client";
import { ActionButtonWithTooltip } from "@/components/ui/action-button";
import { ButtonWithTooltip } from "@/components/ui/button";
import LoadingSpinner from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react";
import { useSession } from "next-auth/react";
import AddUserDialog from "./AddUserDialog";
import ChangeRoleDialog from "./ChangeRoleDialog";
import ResetPasswordDialog from "./ResetPasswordDialog";
function toHumanReadableSize(size: number) {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (size === 0) return "0 Bytes";
const i = Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) + " " + sizes[i];
}
export default function UsersSection() {
const { data: session } = useSession();
const invalidateUserList = api.useUtils().users.list.invalidate;
const { data: users } = api.users.list.useQuery();
const { data: userStats } = api.admin.userStats.useQuery();
const { mutate: deleteUser, isPending: isDeletionPending } =
api.users.delete.useMutation({
onSuccess: () => {
toast({
description: "User deleted",
});
invalidateUserList();
},
onError: (e) => {
toast({
variant: "destructive",
description: `Something went wrong: ${e.message}`,
});
},
});
if (!users || !userStats) {
return <LoadingSpinner />;
}
return (
<>
<div className="mb-2 flex items-center justify-between text-xl font-medium">
<span>Users List</span>
<AddUserDialog>
<ButtonWithTooltip tooltip="Create User" variant="outline">
<UserPlus size={16} />
</ButtonWithTooltip>
</AddUserDialog>
</div>
<Table>
<TableHeader className="bg-gray-200">
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Num Bookmarks</TableHead>
<TableHead>Asset Sizes</TableHead>
<TableHead>Role</TableHead>
<TableHead>Local User</TableHead>
<TableHead>Actions</TableHead>
</TableHeader>
<TableBody>
{users.users.map((u) => (
<TableRow key={u.id}>
<TableCell className="py-1">{u.name}</TableCell>
<TableCell className="py-1">{u.email}</TableCell>
<TableCell className="py-1">
{userStats[u.id].numBookmarks}
</TableCell>
<TableCell className="py-1">
{toHumanReadableSize(userStats[u.id].assetSizes)}
</TableCell>
<TableCell className="py-1 capitalize">{u.role}</TableCell>
<TableCell className="py-1 capitalize">
{u.localUser ? <Check /> : <X />}
</TableCell>
<TableCell className="flex gap-1 py-1">
<ActionButtonWithTooltip
tooltip="Delete user"
variant="outline"
onClick={() => deleteUser({ userId: u.id })}
loading={isDeletionPending}
disabled={session!.user.id == u.id}
>
<Trash size={16} color="red" />
</ActionButtonWithTooltip>
<ResetPasswordDialog userId={u.id}>
<ButtonWithTooltip
tooltip="Reset password"
variant="outline"
disabled={session!.user.id == u.id || !u.localUser}
>
<KeyRound size={16} color="red" />
</ButtonWithTooltip>
</ResetPasswordDialog>
<ChangeRoleDialog userId={u.id} currentRole={u.role!}>
<ButtonWithTooltip
tooltip="Change role"
variant="outline"
disabled={session!.user.id == u.id}
>
<Pencil size={16} color="red" />
</ButtonWithTooltip>
</ChangeRoleDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { ZBookmarkTypeAsset } from "@hoarder/shared/types/bookmarks";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
import { getSourceUrl } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
function AssetImage({
bookmark,
className,
}: {
bookmark: ZBookmarkTypeAsset;
className?: string;
}) {
const bookmarkedAsset = bookmark.content;
switch (bookmarkedAsset.assetType) {
case "image": {
return (
<Link href={`/dashboard/preview/${bookmark.id}`}>
<Image
alt="asset"
src={getAssetUrl(bookmarkedAsset.assetId)}
fill={true}
className={className}
/>
</Link>
);
}
case "pdf": {
return (
<iframe
title={bookmarkedAsset.assetId}
className={className}
src={getAssetUrl(bookmarkedAsset.assetId)}
/>
);
}
default: {
const _exhaustiveCheck: never = bookmarkedAsset.assetType;
return <span />;
}
}
}
export default function AssetCard({
bookmark: bookmarkedAsset,
className,
}: {
bookmark: ZBookmarkTypeAsset;
className?: string;
}) {
return (
<BookmarkLayoutAdaptingCard
title={bookmarkedAsset.title ?? bookmarkedAsset.content.fileName}
footer={
getSourceUrl(bookmarkedAsset) && (
<FooterLinkURL url={getSourceUrl(bookmarkedAsset)} />
)
}
bookmark={bookmarkedAsset}
className={className}
wrapTags={true}
image={(_layout, className) => (
<div className="relative size-full flex-1">
<AssetImage bookmark={bookmarkedAsset} className={className} />
</div>
)}
/>
);
}

View File

@ -0,0 +1,30 @@
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Maximize2 } from "lucide-react";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import BookmarkOptions from "./BookmarkOptions";
import { FavouritedActionIcon } from "./icons";
export default function BookmarkActionBar({
bookmark,
}: {
bookmark: ZBookmark;
}) {
return (
<div className="flex text-gray-500">
{bookmark.favourited && (
<FavouritedActionIcon className="m-1 size-8 rounded p-1" favourited />
)}
<Link
href={`/dashboard/preview/${bookmark.id}`}
className={cn(buttonVariants({ variant: "ghost" }), "px-2")}
>
<Maximize2 size={16} />
</Link>
<BookmarkOptions bookmark={bookmark} />
</div>
);
}

View File

@ -0,0 +1,59 @@
import { api } from "@/lib/trpc";
import { isBookmarkStillLoading } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
import AssetCard from "./AssetCard";
import LinkCard from "./LinkCard";
import TextCard from "./TextCard";
export default function BookmarkCard({
bookmark: initialData,
className,
}: {
bookmark: ZBookmark;
className?: string;
}) {
const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
{
bookmarkId: initialData.id,
},
{
initialData,
refetchInterval: (query) => {
const data = query.state.data;
if (!data) {
return false;
}
if (isBookmarkStillLoading(data)) {
return 1000;
}
return false;
},
},
);
switch (bookmark.content.type) {
case BookmarkTypes.LINK:
return (
<LinkCard
className={className}
bookmark={{ ...bookmark, content: bookmark.content }}
/>
);
case BookmarkTypes.TEXT:
return (
<TextCard
className={className}
bookmark={{ ...bookmark, content: bookmark.content }}
/>
);
case BookmarkTypes.ASSET:
return (
<AssetCard
className={className}
bookmark={{ ...bookmark, content: bookmark.content }}
/>
);
}
}

View File

@ -0,0 +1,253 @@
import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import useBulkActionsStore from "@/lib/bulkActions";
import {
bookmarkLayoutSwitch,
useBookmarkLayout,
} from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
import dayjs from "dayjs";
import { Check, Image as ImageIcon, NotebookPen } from "lucide-react";
import { useTheme } from "next-themes";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { isBookmarkStillTagging } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import BookmarkActionBar from "./BookmarkActionBar";
import TagList from "./TagList";
interface Props {
bookmark: ZBookmark;
image: (layout: BookmarksLayoutTypes, className: string) => React.ReactNode;
title?: React.ReactNode;
content?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
fitHeight?: boolean;
wrapTags: boolean;
}
function BottomRow({
footer,
bookmark,
}: {
footer?: React.ReactNode;
bookmark: ZBookmark;
}) {
return (
<div className="justify flex w-full shrink-0 justify-between text-gray-500">
<div className="flex items-center gap-2 overflow-hidden text-nowrap">
{footer && <>{footer}</>}
<Link
href={`/dashboard/preview/${bookmark.id}`}
suppressHydrationWarning
>
{dayjs(bookmark.createdAt).format("MMM DD")}
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
</div>
);
}
function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) {
const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore();
const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark);
const [isSelected, setIsSelected] = useState(false);
const { theme } = useTheme();
useEffect(() => {
setIsSelected(selectedBookmarks.some((item) => item.id === bookmark.id));
}, [selectedBookmarks]);
if (!isBulkEditEnabled) return null;
const getIconColor = () => {
if (theme === "dark") {
return isSelected ? "black" : "white";
}
return isSelected ? "white" : "black";
};
const getIconBackgroundColor = () => {
if (theme === "dark") {
return isSelected ? "bg-white" : "bg-white bg-opacity-10";
}
return isSelected ? "bg-black" : "bg-white bg-opacity-40";
};
return (
<button
className={cn(
"absolute left-0 top-0 z-50 h-full w-full bg-opacity-0",
{
"bg-opacity-10": isSelected,
},
theme === "dark" ? "bg-white" : "bg-black",
)}
onClick={() => toggleBookmark(bookmark)}
>
<div className="absolute right-2 top-2 z-50 opacity-100">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-full border border-gray-600",
getIconBackgroundColor(),
)}
>
<Check size={12} color={getIconColor()} />
</div>
</div>
</button>
);
}
function ListView({
bookmark,
image,
title,
content,
footer,
className,
}: Props) {
return (
<div
className={cn(
"relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2 shadow-md",
className,
)}
>
<MultiBookmarkSelector bookmark={bookmark} />
<div className="flex size-32 items-center justify-center overflow-hidden">
{image("list", "object-cover rounded-lg size-32")}
</div>
<div className="flex h-full flex-1 flex-col justify-between gap-2 overflow-hidden">
<div className="flex flex-col gap-2 overflow-hidden">
{title && (
<div className="line-clamp-2 flex-none shrink-0 overflow-hidden text-ellipsis break-words text-lg">
{title}
</div>
)}
{content && <div className="shrink-1 overflow-hidden">{content}</div>}
<div className="flex shrink-0 flex-wrap gap-1 overflow-hidden">
<TagList
bookmark={bookmark}
loading={isBookmarkStillTagging(bookmark)}
/>
</div>
</div>
<BottomRow footer={footer} bookmark={bookmark} />
</div>
</div>
);
}
function GridView({
bookmark,
image,
title,
content,
footer,
className,
wrapTags,
layout,
fitHeight = false,
}: Props & { layout: BookmarksLayoutTypes }) {
const img = image("grid", "h-56 min-h-56 w-full object-cover rounded-t-lg");
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg shadow-md",
className,
fitHeight && layout != "grid" ? "max-h-96" : "h-96",
)}
>
<MultiBookmarkSelector bookmark={bookmark} />
{img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>}
<div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2">
<div className="grow-1 flex flex-col gap-2 overflow-hidden">
{title && (
<div className="line-clamp-2 flex-none shrink-0 overflow-hidden text-ellipsis break-words text-lg">
{title}
</div>
)}
{content && <div className="shrink-1 overflow-hidden">{content}</div>}
<div className="flex shrink-0 flex-wrap gap-1 overflow-hidden">
<TagList
className={wrapTags ? undefined : "h-full"}
bookmark={bookmark}
loading={isBookmarkStillTagging(bookmark)}
/>
</div>
</div>
<BottomRow footer={footer} bookmark={bookmark} />
</div>
</div>
);
}
function CompactView({ bookmark, title, footer, className }: Props) {
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg shadow-md",
className,
"max-h-96",
)}
>
<MultiBookmarkSelector bookmark={bookmark} />
<div className="flex h-full justify-between gap-2 overflow-hidden p-2">
<div className="flex items-center gap-2">
{bookmark.content.type === BookmarkTypes.LINK &&
bookmark.content.favicon && (
<Image
src={bookmark.content.favicon}
alt="favicon"
width={5}
unoptimized
height={5}
className="size-5"
/>
)}
{bookmark.content.type === BookmarkTypes.TEXT && (
<NotebookPen className="size-5" />
)}
{bookmark.content.type === BookmarkTypes.ASSET && (
<ImageIcon className="size-5" />
)}
{
<div className="shrink-1 text-md line-clamp-1 overflow-hidden text-ellipsis break-words">
{title ?? "Untitled"}
</div>
}
{footer && (
<p className="flex shrink-0 gap-2 text-gray-500">{footer}</p>
)}
<p className="text-gray-500"></p>
<Link
href={`/dashboard/preview/${bookmark.id}`}
suppressHydrationWarning
className="shrink-0 gap-2 text-gray-500"
>
{dayjs(bookmark.createdAt).format("MMM DD")}
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
</div>
</div>
);
}
export function BookmarkLayoutAdaptingCard(props: Props) {
const layout = useBookmarkLayout();
return bookmarkLayoutSwitch(layout, {
masonry: <GridView layout={layout} {...props} />,
grid: <GridView layout={layout} {...props} />,
list: <ListView {...props} />,
compact: <CompactView {...props} />,
});
}

View File

@ -0,0 +1,245 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/components/ui/use-toast";
import { useClientConfig } from "@/lib/clientConfig";
import {
FileDown,
Link,
List,
ListX,
MoreHorizontal,
Pencil,
RotateCw,
Tags,
Trash2,
} from "lucide-react";
import type {
ZBookmark,
ZBookmarkedLink,
} from "@hoarder/shared/types/bookmarks";
import {
useDeleteBookmark,
useRecrawlBookmark,
useUpdateBookmark,
} from "@hoarder/shared-react/hooks//bookmarks";
import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists";
import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import { BookmarkedTextEditor } from "./BookmarkedTextEditor";
import { ArchivedActionIcon, FavouritedActionIcon } from "./icons";
import { useManageListsModal } from "./ManageListsModal";
import { useTagModel } from "./TagModal";
export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
const { toast } = useToast();
const linkId = bookmark.id;
const demoMode = !!useClientConfig().demoMode;
const { setOpen: setTagModalIsOpen, content: tagModal } =
useTagModel(bookmark);
const { setOpen: setManageListsModalOpen, content: manageListsModal } =
useManageListsModal(bookmark.id);
const [isTextEditorOpen, setTextEditorOpen] = useState(false);
const { listId } = useBookmarkGridContext() ?? {};
const onError = () => {
toast({
variant: "destructive",
title: "Something went wrong",
description: "There was a problem with your request.",
});
};
const deleteBookmarkMutator = useDeleteBookmark({
onSuccess: () => {
toast({
description: "The bookmark has been deleted!",
});
},
onError,
});
const updateBookmarkMutator = useUpdateBookmark({
onSuccess: () => {
toast({
description: "The bookmark has been updated!",
});
},
onError,
});
const crawlBookmarkMutator = useRecrawlBookmark({
onSuccess: () => {
toast({
description: "Re-fetch has been enqueued!",
});
},
onError,
});
const fullPageArchiveBookmarkMutator = useRecrawlBookmark({
onSuccess: () => {
toast({
description: "Full Page Archive creation has been triggered",
});
},
onError,
});
const removeFromListMutator = useRemoveBookmarkFromList({
onSuccess: () => {
toast({
description: "The bookmark has been deleted from the list",
});
},
onError,
});
return (
<>
{tagModal}
{manageListsModal}
<BookmarkedTextEditor
bookmark={bookmark}
open={isTextEditorOpen}
setOpen={setTextEditorOpen}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="px-1 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit">
{bookmark.content.type === BookmarkTypes.TEXT && (
<DropdownMenuItem onClick={() => setTextEditorOpen(true)}>
<Pencil className="mr-2 size-4" />
<span>Edit</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
updateBookmarkMutator.mutate({
bookmarkId: linkId,
favourited: !bookmark.favourited,
})
}
>
<FavouritedActionIcon
className="mr-2 size-4"
favourited={bookmark.favourited}
/>
<span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
updateBookmarkMutator.mutate({
bookmarkId: linkId,
archived: !bookmark.archived,
})
}
>
<ArchivedActionIcon
className="mr-2 size-4"
archived={bookmark.archived}
/>
<span>{bookmark.archived ? "Un-archive" : "Archive"}</span>
</DropdownMenuItem>
{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
onClick={() => {
fullPageArchiveBookmarkMutator.mutate({
bookmarkId: bookmark.id,
archiveFullPage: true,
});
}}
>
<FileDown className="mr-2 size-4" />
<span>Download Full Page Archive</span>
</DropdownMenuItem>
)}
{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
(bookmark.content as ZBookmarkedLink).url,
);
toast({
description: "Link was added to your clipboard!",
});
}}
>
<Link className="mr-2 size-4" />
<span>Copy Link</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setTagModalIsOpen(true)}>
<Tags className="mr-2 size-4" />
<span>Edit Tags</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setManageListsModalOpen(true)}>
<List className="mr-2 size-4" />
<span>Manage Lists</span>
</DropdownMenuItem>
{listId && (
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
removeFromListMutator.mutate({
listId,
bookmarkId: bookmark.id,
})
}
>
<ListX className="mr-2 size-4" />
<span>Remove from List</span>
</DropdownMenuItem>
)}
{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id })
}
>
<RotateCw className="mr-2 size-4" />
<span>Refresh</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={demoMode}
className="text-destructive"
onClick={() =>
deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id })
}
>
<Trash2 className="mr-2 size-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@ -0,0 +1,48 @@
import { toast } from "@/components/ui/use-toast";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
import { TagsEditor } from "./TagsEditor";
export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
const { mutate } = useUpdateBookmarkTags({
onSuccess: () => {
toast({
description: "Tags has been updated!",
});
},
onError: () => {
toast({
variant: "destructive",
title: "Something went wrong",
description: "There was a problem with your request.",
});
},
});
return (
<TagsEditor
tags={bookmark.tags}
onAttach={({ tagName, tagId }) => {
mutate({
bookmarkId: bookmark.id,
attach: [
{
tagName,
tagId,
},
],
detach: [],
});
}}
onDetach={({ tagId }) => {
mutate({
bookmarkId: bookmark.id,
attach: [],
detach: [{ tagId }],
});
}}
/>
);
}

View File

@ -0,0 +1,81 @@
import { useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/use-toast";
import { useUpdateBookmarkText } from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
export function BookmarkedTextEditor({
bookmark,
open,
setOpen,
}: {
bookmark: ZBookmark;
open: boolean;
setOpen: (open: boolean) => void;
}) {
const isNewBookmark = bookmark === undefined;
const [noteText, setNoteText] = useState(
bookmark && bookmark.content.type == BookmarkTypes.TEXT
? bookmark.content.text
: "",
);
const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmarkText({
onSuccess: () => {
toast({
description: "Note updated!",
});
setOpen(false);
},
onError: () => {
toast({ description: "Something went wrong", variant: "destructive" });
},
});
const onSave = () => {
updateBookmarkMutator({
bookmarkId: bookmark.id,
text: noteText,
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isNewBookmark ? "New Note" : "Edit Note"}</DialogTitle>
<DialogDescription>
Write your note with markdown support
</DialogDescription>
</DialogHeader>
<Textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
className="h-52 grow"
/>
<DialogFooter className="flex-shrink gap-1 sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton type="button" loading={isPending} onClick={onSave}>
Save
</ActionButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
import type { ZGetBookmarksRequest } from "@hoarder/shared/types/bookmarks";
import UpdatableBookmarksGrid from "./UpdatableBookmarksGrid";
export default async function Bookmarks({
query,
header,
showDivider,
showEditorCard = false,
}: {
query: ZGetBookmarksRequest;
header?: React.ReactNode;
showDivider?: boolean;
showEditorCard?: boolean;
}) {
const session = await getServerAuthSession();
if (!session) {
redirect("/");
}
const bookmarks = await api.bookmarks.getBookmarks(query);
return (
<div className="flex flex-col gap-3">
{header}
{showDivider && <Separator />}
<UpdatableBookmarksGrid
query={query}
bookmarks={bookmarks}
showEditorCard={showEditorCard}
/>
</div>
);
}

View File

@ -0,0 +1,117 @@
import { useEffect, useMemo } from "react";
import { ActionButton } from "@/components/ui/action-button";
import useBulkActionsStore from "@/lib/bulkActions";
import {
bookmarkLayoutSwitch,
useBookmarkLayout,
} from "@/lib/userLocalSettings/bookmarksLayout";
import tailwindConfig from "@/tailwind.config";
import { Slot } from "@radix-ui/react-slot";
import { useInView } from "react-intersection-observer";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import BookmarkCard from "./BookmarkCard";
import EditorCard from "./EditorCard";
function StyledBookmarkCard({ children }: { children: React.ReactNode }) {
return (
<Slot className="mb-4 border border-border bg-card duration-300 ease-in hover:shadow-lg hover:transition-all">
{children}
</Slot>
);
}
function getBreakpointConfig() {
const fullConfig = resolveConfig(tailwindConfig);
const breakpointColumnsObj: { [key: number]: number; default: number } = {
default: 3,
};
breakpointColumnsObj[parseInt(fullConfig.theme.screens.lg)] = 2;
breakpointColumnsObj[parseInt(fullConfig.theme.screens.md)] = 1;
breakpointColumnsObj[parseInt(fullConfig.theme.screens.sm)] = 1;
return breakpointColumnsObj;
}
export default function BookmarksGrid({
bookmarks,
hasNextPage = false,
fetchNextPage = () => ({}),
isFetchingNextPage = false,
showEditorCard = false,
}: {
bookmarks: ZBookmark[];
showEditorCard?: boolean;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
}) {
const layout = useBookmarkLayout();
const bulkActionsStore = useBulkActionsStore();
const breakpointConfig = useMemo(() => getBreakpointConfig(), []);
const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView();
useEffect(() => {
bulkActionsStore.setVisibleBookmarks(bookmarks);
return () => {
bulkActionsStore.setVisibleBookmarks([]);
};
}, [bookmarks]);
useEffect(() => {
if (loadMoreButtonInView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [loadMoreButtonInView]);
if (bookmarks.length == 0 && !showEditorCard) {
return <p>No bookmarks</p>;
}
const children = [
showEditorCard && (
<StyledBookmarkCard key={"editor"}>
<EditorCard />
</StyledBookmarkCard>
),
...bookmarks.map((b) => (
<StyledBookmarkCard key={b.id}>
<BookmarkCard bookmark={b} />
</StyledBookmarkCard>
)),
];
return (
<>
{bookmarkLayoutSwitch(layout, {
masonry: (
<Masonry className="flex gap-4" breakpointCols={breakpointConfig}>
{children}
</Masonry>
),
grid: (
<Masonry className="flex gap-4" breakpointCols={breakpointConfig}>
{children}
</Masonry>
),
list: <div className="grid grid-cols-1">{children}</div>,
compact: <div className="grid grid-cols-1">{children}</div>,
})}
{hasNextPage && (
<div className="flex justify-center">
<ActionButton
ref={loadMoreRef}
ignoreDemoMode={true}
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
variant="ghost"
>
Load More
</ActionButton>
</div>
)}
</>
);
}

View File

@ -0,0 +1,138 @@
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { toast } from "@/components/ui/use-toast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAddBookmarkToList } from "@hoarder/shared-react/hooks/lists";
import { BookmarkListSelector } from "../lists/BookmarkListSelector";
export default function BulkManageListsModal({
bookmarkIds,
open,
setOpen,
}: {
bookmarkIds: string[];
open: boolean;
setOpen: (open: boolean) => void;
}) {
const formSchema = z.object({
listId: z.string({
required_error: "Please select a list",
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
listId: undefined,
},
});
const { mutateAsync: addToList, isPending: isAddingToListPending } =
useAddBookmarkToList({
onSettled: () => {
form.resetField("listId");
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
const onSubmit = async (value: z.infer<typeof formSchema>) => {
const results = await Promise.allSettled(
bookmarkIds.map((bookmarkId) =>
addToList({
bookmarkId,
listId: value.listId,
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
if (successes > 0) {
toast({
description: `${successes} bookmarks have been added to the list!`,
});
}
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<Form {...form}>
<form
className="flex w-full flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<DialogHeader>
<DialogTitle>
Add {bookmarkIds.length} bookmarks to List
</DialogTitle>
</DialogHeader>
<FormField
control={form.control}
name="listId"
render={({ field }) => {
return (
<FormItem>
<FormControl>
<BookmarkListSelector
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton
type="submit"
loading={isAddingToListPending}
disabled={isAddingToListPending}
>
Add
</ActionButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,126 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";
import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
import { api } from "@hoarder/shared-react/trpc";
import { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { TagsEditor } from "./TagsEditor";
export default function BulkTagModal({
bookmarkIds,
open,
setOpen,
}: {
bookmarkIds: string[];
open: boolean;
setOpen: (open: boolean) => void;
}) {
const results = api.useQueries((t) =>
bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })),
);
const bookmarks = results
.map((r) => r.data)
.filter((b): b is ZBookmark => !!b);
const { mutateAsync } = useUpdateBookmarkTags({
onError: (err) => {
if (err.data?.code == "BAD_REQUEST") {
if (err.data.zodError) {
toast({
variant: "destructive",
description: Object.values(err.data.zodError.fieldErrors)
.flat()
.join("\n"),
});
} else {
toast({
variant: "destructive",
description: err.message,
});
}
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
const onAttach = async (tag: { tagName: string; tagId?: string }) => {
const results = await Promise.allSettled(
bookmarkIds.map((id) =>
mutateAsync({
bookmarkId: id,
attach: [tag],
detach: [],
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
toast({
description: `Tag "${tag.tagName}" has been added to ${successes} bookmarks!`,
});
};
const onDetach = async ({
tagId,
tagName,
}: {
tagId: string;
tagName: string;
}) => {
const results = await Promise.allSettled(
bookmarkIds.map((id) =>
mutateAsync({
bookmarkId: id,
attach: [],
detach: [{ tagId }],
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
toast({
description: `Tag "${tagName}" has been removed from ${successes} bookmarks!`,
});
};
// Get all the tags that are attached to all the bookmarks
let tags = bookmarks
.flatMap((b) => b.tags)
.filter((tag) =>
bookmarks.every((b) => b.tags.some((t) => tag.id == t.id)),
);
// Filter duplicates
tags = tags.filter(
(tag, index, self) => index === self.findIndex((t) => t.id == tag.id),
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tags of {bookmarks.length} Bookmarks</DialogTitle>
</DialogHeader>
<TagsEditor tags={tags} onAttach={onAttach} onDetach={onDetach} />
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,279 @@
import type { SubmitErrorHandler, SubmitHandler } from "react-hook-form";
import React, { useEffect, useImperativeHandle, useRef } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Form, FormControl, FormItem } from "@/components/ui/form";
import InfoTooltip from "@/components/ui/info-tooltip";
import MultipleChoiceDialog from "@/components/ui/multiple-choice-dialog";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/use-toast";
import BookmarkAlreadyExistsToast from "@/components/utils/BookmarkAlreadyExistsToast";
import { useClientConfig } from "@/lib/clientConfig";
import {
useBookmarkLayout,
useBookmarkLayoutSwitch,
} from "@/lib/userLocalSettings/bookmarksLayout";
import { cn, getOS } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useCreateBookmarkWithPostHook } from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import { useUploadAsset } from "../UploadDropzone";
function useFocusOnKeyPress(inputRef: React.RefObject<HTMLTextAreaElement>) {
useEffect(() => {
function handleKeyPress(e: KeyboardEvent) {
if (!inputRef.current) {
return;
}
if ((e.metaKey || e.ctrlKey) && e.code === "KeyE") {
inputRef.current.focus();
e.preventDefault();
}
}
document.addEventListener("keydown", handleKeyPress);
return () => {
document.removeEventListener("keydown", handleKeyPress);
};
}, [inputRef]);
}
interface MultiUrlImportState {
urls: URL[];
text: string;
}
export default function EditorCard({ className }: { className?: string }) {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [multiUrlImportState, setMultiUrlImportState] =
React.useState<MultiUrlImportState | null>(null);
const demoMode = !!useClientConfig().demoMode;
const bookmarkLayout = useBookmarkLayout();
const formSchema = z.object({
text: z.string(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
text: "",
},
});
const { ref, ...textFieldProps } = form.register("text");
useImperativeHandle(ref, () => inputRef.current);
useFocusOnKeyPress(inputRef);
const { mutate, isPending } = useCreateBookmarkWithPostHook({
onSuccess: (resp) => {
if (resp.alreadyExists) {
toast({
description: <BookmarkAlreadyExistsToast bookmarkId={resp.id} />,
variant: "default",
});
}
form.reset();
// if the list layout is used, we reset the size of the editor card to the original size after submitting
if (bookmarkLayout === "list" && inputRef?.current?.style) {
inputRef.current.style.height = "auto";
}
},
onError: (e) => {
toast({ description: e.message, variant: "destructive" });
},
});
const uploadAsset = useUploadAsset();
function tryToImportUrls(text: string): void {
const lines = text.split("\n");
const urls: URL[] = [];
for (const line of lines) {
// parsing can also throw an exception, but will be caught outside
const url = new URL(line);
if (url.protocol != "http:" && url.protocol != "https:") {
throw new Error("Invalid URL");
}
urls.push(url);
}
if (urls.length === 1) {
// Only 1 url in the textfield --> simply import it
mutate({ type: BookmarkTypes.LINK, url: text });
return;
}
// multiple urls found --> ask the user if it should be imported as multiple URLs or as a text bookmark
setMultiUrlImportState({ urls, text });
}
const onInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
// Expand the textarea to a max of half the screen size in the list layout only
if (bookmarkLayout === "list") {
const target = e.target as HTMLTextAreaElement;
const maxHeight = window.innerHeight * 0.5;
target.style.height = "auto";
if (target.scrollHeight <= maxHeight) {
target.style.height = `${target.scrollHeight}px`;
} else {
target.style.height = `${maxHeight}px`;
}
}
};
const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = (data) => {
const text = data.text.trim();
if (!text.length) return;
try {
tryToImportUrls(text);
} catch (e) {
// Not a URL
mutate({ type: BookmarkTypes.TEXT, text });
}
};
const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => {
toast({
description: Object.values(errors)
.map((v) => v.message)
.join("\n"),
variant: "destructive",
});
};
const cardHeight = useBookmarkLayoutSwitch({
grid: "h-96",
masonry: "h-96",
list: undefined,
compact: undefined,
});
const handlePaste = async (
event: React.ClipboardEvent<HTMLTextAreaElement>,
) => {
if (event?.clipboardData?.items) {
await Promise.all(
Array.from(event.clipboardData.items)
.filter((item) => item?.type?.startsWith("image"))
.map((item) => {
const blob = item.getAsFile();
if (blob) {
return uploadAsset(blob);
}
}),
);
}
};
const OS = getOS();
return (
<Form {...form}>
<form
className={cn(
className,
"relative flex flex-col gap-2 rounded-xl bg-card p-4",
cardHeight,
)}
onSubmit={form.handleSubmit(onSubmit, onError)}
>
<div className="flex justify-between">
<p className="text-sm">NEW ITEM</p>
<InfoTooltip size={15}>
<p className="text-center">
You can quickly focus on this field by pressing + E
</p>
</InfoTooltip>
</div>
<Separator />
<FormItem className="flex-1">
<FormControl>
<Textarea
ref={inputRef}
disabled={isPending}
className={cn(
"h-full w-full border-none p-0 text-lg focus-visible:ring-0",
{ "resize-none": bookmarkLayout !== "list" },
)}
placeholder={
"Paste a link or an image, write a note or drag and drop an image in here ..."
}
onKeyDown={(e) => {
if (demoMode) {
return;
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
form.handleSubmit(onSubmit, onError)();
}
}}
onPaste={(e) => {
if (demoMode) {
return;
}
handlePaste(e);
}}
onInput={onInput}
{...textFieldProps}
/>
</FormControl>
</FormItem>
<ActionButton loading={isPending} type="submit" variant="default">
{form.formState.dirtyFields.text
? demoMode
? "Submissions are disabled"
: `Save (${OS === "macos" ? "⌘" : "Ctrl"} + Enter)`
: "Save"}
</ActionButton>
{multiUrlImportState && (
<MultipleChoiceDialog
open={true}
title={`Import URLs as separate Bookmarks?`}
description={`The input contains multiple URLs on separate lines. Do you want to import them as separate bookmarks?`}
onOpenChange={(open) => {
if (!open) {
setMultiUrlImportState(null);
}
}}
actionButtons={[
() => (
<ActionButton
type="button"
variant="secondary"
loading={isPending}
onClick={() => {
mutate({
type: BookmarkTypes.TEXT,
text: multiUrlImportState.text,
});
setMultiUrlImportState(null);
}}
>
Import as Text Bookmark
</ActionButton>
),
() => (
<ActionButton
type="button"
variant="destructive"
loading={isPending}
onClick={() => {
multiUrlImportState.urls.forEach((url) =>
mutate({ type: BookmarkTypes.LINK, url: url.toString() }),
);
setMultiUrlImportState(null);
}}
>
Import as separate Bookmarks
</ActionButton>
),
]}
></MultipleChoiceDialog>
)}
</form>
</Form>
);
}

View File

@ -0,0 +1,18 @@
import Link from "next/link";
export default function FooterLinkURL({ url }: { url: string | null }) {
if (!url) {
return null;
}
const parsedUrl = new URL(url);
return (
<Link
className="line-clamp-1 hover:text-foreground"
href={url}
target="_blank"
rel="noreferrer"
>
{parsedUrl.host}
</Link>
);
}

View File

@ -0,0 +1,92 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { ZBookmarkTypeLink } from "@hoarder/shared/types/bookmarks";
import {
getBookmarkLinkImageUrl,
getSourceUrl,
isBookmarkStillCrawling,
} from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
function LinkTitle({ bookmark }: { bookmark: ZBookmarkTypeLink }) {
const link = bookmark.content;
const parsedUrl = new URL(link.url);
return (
<Link href={link.url} target="_blank" rel="noreferrer">
{bookmark.title ?? link?.title ?? parsedUrl.host}
</Link>
);
}
function LinkImage({
bookmark,
className,
}: {
bookmark: ZBookmarkTypeLink;
className?: string;
}) {
const link = bookmark.content;
const imgComponent = (url: string, unoptimized: boolean) => (
<Image
unoptimized={unoptimized}
className={className}
alt="card banner"
fill={true}
src={url}
/>
);
const imageDetails = getBookmarkLinkImageUrl(link);
let img: React.ReactNode;
if (isBookmarkStillCrawling(bookmark)) {
img = imgComponent("/blur.avif", false);
} else if (imageDetails) {
img = imgComponent(imageDetails.url, !imageDetails.localAsset);
} else {
// No image found
// A dummy white pixel for when there's no image.
img = imgComponent(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII=",
true,
);
}
return (
<Link
href={link.url}
target="_blank"
rel="noreferrer"
className={className}
>
<div className="relative size-full flex-1">{img}</div>
</Link>
);
}
export default function LinkCard({
bookmark: bookmarkLink,
className,
}: {
bookmark: ZBookmarkTypeLink;
className?: string;
}) {
return (
<BookmarkLayoutAdaptingCard
title={<LinkTitle bookmark={bookmarkLink} />}
footer={<FooterLinkURL url={getSourceUrl(bookmarkLink)} />}
bookmark={bookmarkLink}
wrapTags={false}
image={(_layout, className) => (
<LinkImage className={className} bookmark={bookmarkLink} />
)}
className={className}
/>
);
}

View File

@ -0,0 +1,226 @@
import { useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import LoadingSpinner from "@/components/ui/spinner";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { Archive, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
useAddBookmarkToList,
useBookmarkLists,
useRemoveBookmarkFromList,
} from "@hoarder/shared-react/hooks/lists";
import { BookmarkListSelector } from "../lists/BookmarkListSelector";
import ArchiveBookmarkButton from "./action-buttons/ArchiveBookmarkButton";
export default function ManageListsModal({
bookmarkId,
open,
setOpen,
}: {
bookmarkId: string;
open: boolean;
setOpen: (open: boolean) => void;
}) {
const formSchema = z.object({
listId: z.string({
required_error: "Please select a list",
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
listId: undefined,
},
});
const { data: allLists, isPending: isAllListsPending } = useBookmarkLists(
undefined,
{ enabled: open },
);
const { data: alreadyInList, isPending: isAlreadyInListPending } =
api.lists.getListsOfBookmark.useQuery(
{
bookmarkId,
},
{ enabled: open },
);
const isLoading = isAllListsPending || isAlreadyInListPending;
const { mutate: addToList, isPending: isAddingToListPending } =
useAddBookmarkToList({
onSuccess: () => {
toast({
description: "List has been updated!",
});
form.resetField("listId");
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
const { mutate: deleteFromList, isPending: isDeleteFromListPending } =
useRemoveBookmarkFromList({
onSuccess: () => {
toast({
description: "List has been updated!",
});
form.resetField("listId");
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit((value) => {
addToList({
bookmarkId: bookmarkId,
listId: value.listId,
});
})}
>
<DialogHeader>
<DialogTitle>Manage Lists</DialogTitle>
</DialogHeader>
{isLoading ? (
<LoadingSpinner className="my-4" />
) : (
allLists && (
<ul className="flex flex-col gap-2 pb-2 pt-4">
{alreadyInList?.lists.map((list) => (
<li
key={list.id}
className="flex items-center justify-between rounded-lg border border-border bg-background px-2 py-1 text-foreground"
>
<p>
{allLists
.getPathById(list.id)!
.map((l) => `${l.icon} ${l.name}`)
.join(" / ")}
</p>
<ActionButton
type="button"
variant="ghost"
size="sm"
loading={isDeleteFromListPending}
onClick={() =>
deleteFromList({ bookmarkId, listId: list.id })
}
>
<X className="size-4" />
</ActionButton>
</li>
))}
</ul>
)
)}
<div className="pb-4">
<FormField
control={form.control}
name="listId"
render={({ field }) => {
return (
<FormItem>
<FormControl>
<BookmarkListSelector
value={field.value}
hideBookmarkIds={alreadyInList?.lists.map(
(l) => l.id,
)}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ArchiveBookmarkButton
type="button"
bookmarkId={bookmarkId}
onDone={() => setOpen(false)}
>
<Archive className="mr-2 size-4" /> Archive
</ArchiveBookmarkButton>
<ActionButton
type="submit"
loading={isAddingToListPending}
disabled={isAddingToListPending}
>
Add
</ActionButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
export function useManageListsModal(bookmarkId: string) {
const [open, setOpen] = useState(false);
return {
open,
setOpen,
content: open && (
<ManageListsModal bookmarkId={bookmarkId} open={open} setOpen={setOpen} />
),
};
}

View File

@ -0,0 +1,142 @@
import React from "react";
import { ActionButton } from "@/components/ui/action-button";
import LoadingSpinner from "@/components/ui/spinner";
import { toast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { ChevronUp, RefreshCw, Sparkles, Trash2 } from "lucide-react";
import {
useSummarizeBookmark,
useUpdateBookmark,
} from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
function AISummary({
bookmarkId,
summary,
}: {
bookmarkId: string;
summary: string;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const { mutate: resummarize, isPending: isResummarizing } =
useSummarizeBookmark({
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
});
const { mutate: updateBookmark, isPending: isUpdatingBookmark } =
useUpdateBookmark({
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
});
return (
<div className="w-full p-1">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={`
relative overflow-hidden rounded-lg p-4
transition-all duration-300 ease-in-out
${isExpanded ? "h-auto" : "cursor-pointer"}
bg-gradient-to-r from-purple-400 via-pink-500 to-red-500 p-[2px]
`}
onClick={() => !isExpanded && setIsExpanded(true)}
>
<div className="h-full rounded-lg bg-accent p-2">
<p
className={`text-sm text-gray-700 dark:text-gray-300 ${!isExpanded && "line-clamp-3"}`}
>
{summary}
</p>
{isExpanded && (
<span className="flex justify-end gap-2 pt-2">
<ActionButton
variant="none"
size="none"
spinner={<LoadingSpinner className="size-4" />}
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label={isExpanded ? "Collapse" : "Expand"}
loading={isResummarizing}
onClick={() => resummarize({ bookmarkId })}
>
<RefreshCw size={16} />
</ActionButton>
<ActionButton
size="none"
variant="none"
spinner={<LoadingSpinner className="size-4" />}
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label={isExpanded ? "Collapse" : "Expand"}
loading={isUpdatingBookmark}
onClick={() => updateBookmark({ bookmarkId, summary: null })}
>
<Trash2 size={16} />
</ActionButton>
<button
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label="Collapse"
onClick={() => setIsExpanded(false)}
>
<ChevronUp size={16} />
</button>
</span>
)}
</div>
</div>
</div>
);
}
export default function SummarizeBookmarkArea({
bookmark,
}: {
bookmark: ZBookmark;
}) {
const { mutate, isPending } = useSummarizeBookmark({
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
});
if (bookmark.content.type !== BookmarkTypes.LINK) {
return null;
}
if (bookmark.summary) {
return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />;
} else {
return (
<div className="flex w-full items-center gap-4">
<ActionButton
onClick={() => mutate({ bookmarkId: bookmark.id })}
className={cn(
`relative w-full overflow-hidden bg-opacity-30 bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500 transition-all duration-300`,
isPending ? "text-transparent" : "text-gray-50",
)}
loading={isPending}
>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-full w-full bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500"></div>
<LoadingSpinner className="absolute h-5 w-5 text-white" />
</div>
)}
<span className="relative z-10 flex items-center gap-1.5">
Summarize with AI
<Sparkles className="size-4" />
</span>
</ActionButton>
</div>
);
}
}

View File

@ -0,0 +1,43 @@
import Link from "next/link";
import { badgeVariants } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
export default function TagList({
bookmark,
loading,
className,
}: {
bookmark: ZBookmark;
loading?: boolean;
className?: string;
}) {
if (loading) {
return (
<div className="flex w-full flex-col justify-end space-y-2 p-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<>
{bookmark.tags.map((t) => (
<div key={t.id} className={className}>
<Link
key={t.id}
className={cn(
badgeVariants({ variant: "outline" }),
"text-nowrap font-normal hover:bg-foreground hover:text-secondary",
)}
href={`/dashboard/tags/${t.id}`}
>
{t.name}
</Link>
</div>
))}
</>
);
}

View File

@ -0,0 +1,59 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { BookmarkTagsEditor } from "./BookmarkTagsEditor";
export default function TagModal({
bookmark,
open,
setOpen,
}: {
bookmark: ZBookmark;
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tags</DialogTitle>
</DialogHeader>
<BookmarkTagsEditor bookmark={bookmark} />
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function useTagModel(bookmark: ZBookmark) {
const [open, setOpen] = useState(false);
return {
open,
setOpen,
content: (
<TagModal
key={bookmark.id}
bookmark={bookmark}
open={open}
setOpen={setOpen}
/>
),
};
}

View File

@ -0,0 +1,191 @@
import type { ActionMeta } from "react-select";
import { useState } from "react";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import CreateableSelect from "react-select/creatable";
import type {
ZAttachedByEnum,
ZBookmarkTags,
} from "@hoarder/shared/types/tags";
interface EditableTag {
attachedBy: ZAttachedByEnum;
value?: string;
label: string;
}
export function TagsEditor({
tags: _tags,
onAttach,
onDetach,
}: {
tags: ZBookmarkTags[];
onAttach: (tag: { tagName: string; tagId?: string }) => void;
onDetach: (tag: { tagName: string; tagId: string }) => void;
}) {
const demoMode = !!useClientConfig().demoMode;
const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags);
const { data: existingTags, isLoading: isExistingTagsLoading } =
api.tags.list.useQuery();
existingTags?.tags.sort((a, b) => a.name.localeCompare(b.name));
const onChange = (
_option: readonly EditableTag[],
actionMeta: ActionMeta<EditableTag>,
) => {
switch (actionMeta.action) {
case "pop-value":
case "remove-value": {
if (actionMeta.removedValue.value) {
setOptimisticTags((prev) =>
prev.filter((t) => t.id != actionMeta.removedValue.value),
);
onDetach({
tagId: actionMeta.removedValue.value,
tagName: actionMeta.removedValue.label,
});
}
break;
}
case "create-option": {
setOptimisticTags((prev) => [
...prev,
{
id: "",
name: actionMeta.option.label,
attachedBy: "human" as const,
},
]);
onAttach({ tagName: actionMeta.option.label });
break;
}
case "select-option": {
if (actionMeta.option) {
setOptimisticTags((prev) => [
...prev,
{
id: actionMeta.option?.value ?? "",
name: actionMeta.option!.label,
attachedBy: "human" as const,
},
]);
onAttach({
tagName: actionMeta.option.label,
tagId: actionMeta.option?.value,
});
}
break;
}
}
};
return (
<CreateableSelect
isDisabled={demoMode}
onChange={onChange}
options={
existingTags?.tags.map((t) => ({
label: t.name,
value: t.id,
attachedBy: "human" as const,
})) ?? []
}
value={optimisticTags.map((t) => ({
label: t.name,
value: t.id,
attachedBy: t.attachedBy,
}))}
isMulti
closeMenuOnSelect={false}
isClearable={false}
isLoading={isExistingTagsLoading}
theme={(theme) => ({
...theme,
// This color scheme doesn't support disabled options.
colors: {
...theme.colors,
primary: "hsl(var(--accent))",
primary50: "hsl(var(--accent))",
primary75: "hsl(var(--accent))",
primary25: "hsl(var(--accent))",
},
})}
styles={{
multiValueRemove: () => ({
backgroundColor: "transparent",
}),
valueContainer: (styles) => ({
...styles,
padding: "0.5rem",
maxHeight: "14rem",
overflowY: "auto",
scrollbarWidth: "thin",
}),
container: (styles) => ({
...styles,
width: "100%",
}),
control: (styles) => ({
...styles,
overflow: "hidden",
backgroundColor: "hsl(var(--background))",
borderColor: "hsl(var(--border))",
":hover": {
borderColor: "hsl(var(--border))",
},
}),
input: (styles) => ({
...styles,
color: "rgb(156 163 175)",
}),
menu: (styles) => ({
...styles,
overflow: "hidden",
color: "rgb(156 163 175)",
}),
placeholder: (styles) => ({
...styles,
color: "hsl(var(--muted-foreground))",
}),
}}
components={{
MultiValueContainer: ({ children, data }) => (
<div
className={cn(
"flex min-h-8 space-x-1 rounded px-2",
(data as { attachedBy: string }).attachedBy == "ai"
? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white"
: "bg-accent",
)}
>
{children}
</div>
),
MultiValueLabel: ({ children, data }) => (
<div className="m-auto flex gap-2">
{(data as { attachedBy: string }).attachedBy == "ai" && (
<Sparkles className="m-auto size-4" />
)}
{children}
</div>
),
DropdownIndicator: () => <span />,
IndicatorSeparator: () => <span />,
}}
classNames={{
multiValueRemove: () => "my-auto",
valueContainer: () => "gap-2 bg-background text-sm",
menu: () => "dark:text-gray-300",
menuList: () => "bg-background text-sm",
option: () => "text-red-500",
input: () => "dark:text-gray-300",
}}
/>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { MarkdownComponent } from "@/components/ui/markdown-component";
import { bookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
import type { ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
import { getSourceUrl } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
export default function TextCard({
bookmark,
className,
}: {
bookmark: ZBookmarkTypeText;
className?: string;
}) {
const bookmarkedText = bookmark.content;
const banner = bookmark.assets.find((a) => a.assetType == "bannerImage");
return (
<>
<BookmarkLayoutAdaptingCard
title={bookmark.title}
content={<MarkdownComponent>{bookmarkedText.text}</MarkdownComponent>}
footer={
getSourceUrl(bookmark) && (
<FooterLinkURL url={getSourceUrl(bookmark)} />
)
}
wrapTags={true}
bookmark={bookmark}
className={className}
fitHeight={true}
image={(layout, className) =>
bookmarkLayoutSwitch(layout, {
grid: null,
masonry: null,
compact: null,
list: banner ? (
<div className="relative size-full flex-1">
<Link href={`/dashboard/preview/${bookmark.id}`}>
<Image
alt="card banner"
fill={true}
className={cn("flex-1", className)}
src={getAssetUrl(banner.id)}
/>
</Link>
</div>
) : (
<div
className={cn(
"flex size-full items-center justify-center bg-accent text-center",
className,
)}
>
Note
</div>
),
})
}
/>
</>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import UploadDropzone from "@/components/dashboard/UploadDropzone";
import { api } from "@/lib/trpc";
import type {
ZGetBookmarksRequest,
ZGetBookmarksResponse,
} from "@hoarder/shared/types/bookmarks";
import { BookmarkGridContextProvider } from "@hoarder/shared-react/hooks/bookmark-grid-context";
import BookmarksGrid from "./BookmarksGrid";
export default function UpdatableBookmarksGrid({
query,
bookmarks: initialBookmarks,
showEditorCard = false,
}: {
query: ZGetBookmarksRequest;
bookmarks: ZGetBookmarksResponse;
showEditorCard?: boolean;
itemsPerPage?: number;
}) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
api.bookmarks.getBookmarks.useInfiniteQuery(
{ ...query, useCursorV2: true },
{
initialData: () => ({
pages: [initialBookmarks],
pageParams: [query.cursor],
}),
initialCursor: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
const grid = (
<BookmarksGrid
bookmarks={data!.pages.flatMap((b) => b.bookmarks)}
hasNextPage={hasNextPage}
fetchNextPage={() => fetchNextPage()}
isFetchingNextPage={isFetchingNextPage}
showEditorCard={showEditorCard}
/>
);
return (
<BookmarkGridContextProvider query={query}>
{showEditorCard ? <UploadDropzone>{grid}</UploadDropzone> : grid}
</BookmarkGridContextProvider>
);
}

View File

@ -0,0 +1,64 @@
import React from "react";
import { ActionButton, ActionButtonProps } from "@/components/ui/action-button";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks";
interface ArchiveBookmarkButtonProps
extends Omit<ActionButtonProps, "loading" | "disabled"> {
bookmarkId: string;
onDone?: () => void;
}
const ArchiveBookmarkButton = React.forwardRef<
HTMLButtonElement,
ArchiveBookmarkButtonProps
>(({ bookmarkId, onDone, ...props }, ref) => {
const { data } = api.bookmarks.getBookmark.useQuery({ bookmarkId });
const { mutate: updateBookmark, isPending: isArchivingBookmark } =
useUpdateBookmark({
onSuccess: () => {
toast({
description: "Bookmark has been archived!",
});
onDone?.();
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
if (!data) {
return <span />;
}
return (
<ActionButton
ref={ref}
loading={isArchivingBookmark}
disabled={data.archived}
onClick={() =>
updateBookmark({
bookmarkId,
archived: !data.archived,
})
}
{...props}
/>
);
});
ArchiveBookmarkButton.displayName = "ArchiveBookmarkButton";
export default ArchiveBookmarkButton;

View File

@ -0,0 +1,33 @@
import { Archive, ArchiveRestore, Star } from "lucide-react";
export function FavouritedActionIcon({
favourited,
className,
size,
}: {
favourited: boolean;
className?: string;
size?: number;
}) {
return favourited ? (
<Star size={size} className={className} color="#ebb434" fill="#ebb434" />
) : (
<Star size={size} className={className} />
);
}
export function ArchivedActionIcon({
archived,
className,
size,
}: {
archived: boolean;
className?: string;
size?: number;
}) {
return archived ? (
<ArchiveRestore size={size} className={className} />
) : (
<Archive size={size} className={className} />
);
}

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