Big edit: Got auth working and can sign into a dashboard. Lot of crust to delete though.
This commit is contained in:
parent
c023fa1730
commit
0fd8ce7189
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
36
apps/web/.gitignore
vendored
@ -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
|
||||
@ -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.
|
||||
|
||||
56
apps/web/app/api/assets/[assetId]/route.ts
Normal file
56
apps/web/app/api/assets/[assetId]/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
77
apps/web/app/api/assets/route.ts
Normal file
77
apps/web/app/api/assets/route.ts
Normal 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);
|
||||
}
|
||||
3
apps/web/app/api/auth/[...nextauth]/route.tsx
Normal file
3
apps/web/app/api/auth/[...nextauth]/route.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { authHandler } from "@/server/auth";
|
||||
|
||||
export { authHandler as GET, authHandler as POST };
|
||||
40
apps/web/app/api/bookmarks/export/route.tsx
Normal file
40
apps/web/app/api/bookmarks/export/route.tsx
Normal 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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = async (_req: NextRequest) => {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
message: "Web app is working",
|
||||
});
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
message: "Web app is working",
|
||||
});
|
||||
};
|
||||
|
||||
21
apps/web/app/api/trpc/[trpc]/route.ts
Normal file
21
apps/web/app/api/trpc/[trpc]/route.ts
Normal 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 };
|
||||
@ -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 };
|
||||
18
apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts
Normal file
18
apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
50
apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts
Normal file
50
apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
45
apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts
Normal file
45
apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts
Normal 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 } };
|
||||
},
|
||||
});
|
||||
37
apps/web/app/api/v1/bookmarks/route.ts
Normal file
37
apps/web/app/api/v1/bookmarks/route.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
18
apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts
Normal file
18
apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts
Normal 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) };
|
||||
},
|
||||
});
|
||||
55
apps/web/app/api/v1/lists/[listId]/route.ts
Normal file
55
apps/web/app/api/v1/lists/[listId]/route.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
26
apps/web/app/api/v1/lists/route.ts
Normal file
26
apps/web/app/api/v1/lists/route.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
25
apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts
Normal file
25
apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts
Normal 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),
|
||||
};
|
||||
},
|
||||
});
|
||||
55
apps/web/app/api/v1/tags/[tagId]/route.ts
Normal file
55
apps/web/app/api/v1/tags/[tagId]/route.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
14
apps/web/app/api/v1/tags/route.ts
Normal file
14
apps/web/app/api/v1/tags/route.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
170
apps/web/app/api/v1/utils/handler.ts
Normal file
170
apps/web/app/api/v1/utils/handler.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
32
apps/web/app/api/v1/utils/pagination.ts
Normal file
32
apps/web/app/api/v1/utils/pagination.ts
Normal 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()}`,
|
||||
};
|
||||
}
|
||||
6
apps/web/app/api/v1/utils/types.ts
Normal file
6
apps/web/app/api/v1/utils/types.ts
Normal 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
BIN
apps/web/app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@ -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>
|
||||
);
|
||||
}
|
||||
3
apps/web/app/dashboard/@modal/[...catchAll]/page.tsx
Normal file
3
apps/web/app/dashboard/@modal/[...catchAll]/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function CatchAll() {
|
||||
return null;
|
||||
}
|
||||
3
apps/web/app/dashboard/@modal/default.tsx
Normal file
3
apps/web/app/dashboard/@modal/default.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
23
apps/web/app/dashboard/admin/page.tsx
Normal file
23
apps/web/app/dashboard/admin/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
apps/web/app/dashboard/archive/page.tsx
Normal file
24
apps/web/app/dashboard/archive/page.tsx
Normal 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't appear in the homepage</p>
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ArchivedBookmarkPage() {
|
||||
return (
|
||||
<Bookmarks
|
||||
header={header()}
|
||||
query={{ archived: true }}
|
||||
showDivider={true}
|
||||
showEditorCard={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
apps/web/app/dashboard/bookmarks/page.tsx
Normal file
10
apps/web/app/dashboard/bookmarks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/web/app/dashboard/cleanups/page.tsx
Normal file
21
apps/web/app/dashboard/cleanups/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/app/dashboard/error.tsx
Normal file
9
apps/web/app/dashboard/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web/app/dashboard/favourites/page.tsx
Normal file
16
apps/web/app/dashboard/favourites/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
apps/web/app/dashboard/feeds/[feedId]/page.tsx
Normal file
31
apps/web/app/dashboard/feeds/[feedId]/page.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
apps/web/app/dashboard/layout.tsx
Normal file
37
apps/web/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/web/app/dashboard/lists/[listId]/page.tsx
Normal file
32
apps/web/app/dashboard/lists/[listId]/page.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
apps/web/app/dashboard/lists/page.tsx
Normal file
15
apps/web/app/dashboard/lists/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/web/app/dashboard/not-found.tsx
Normal file
7
apps/web/app/dashboard/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/web/app/dashboard/preview/[bookmarkId]/page.tsx
Normal file
30
apps/web/app/dashboard/preview/[bookmarkId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web/app/dashboard/search/page.tsx
Normal file
28
apps/web/app/dashboard/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/web/app/dashboard/tags/[tagId]/page.tsx
Normal file
47
apps/web/app/dashboard/tags/[tagId]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
apps/web/app/dashboard/tags/page.tsx
Normal file
15
apps/web/app/dashboard/tags/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/app/dashboard/today/page.tsx
Normal file
9
apps/web/app/dashboard/today/page.tsx
Normal 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.
@ -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
BIN
apps/web/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@ -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}`}>
|
||||
{children}
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
5
apps/web/app/settings/ai/page.tsx
Normal file
5
apps/web/app/settings/ai/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import AISettings from "@/components/settings/AISettings";
|
||||
|
||||
export default function AISettingsPage() {
|
||||
return <AISettings />;
|
||||
}
|
||||
9
apps/web/app/settings/api-keys/page.tsx
Normal file
9
apps/web/app/settings/api-keys/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/settings/feeds/page.tsx
Normal file
5
apps/web/app/settings/feeds/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import FeedSettings from "@/components/settings/FeedSettings";
|
||||
|
||||
export default function FeedSettingsPage() {
|
||||
return <FeedSettings />;
|
||||
}
|
||||
9
apps/web/app/settings/import/page.tsx
Normal file
9
apps/web/app/settings/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/web/app/settings/info/page.tsx
Normal file
11
apps/web/app/settings/info/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/app/settings/layout.tsx
Normal file
34
apps/web/app/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
apps/web/app/settings/page.tsx
Normal file
6
apps/web/app/settings/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SettingsHomepage() {
|
||||
redirect("/settings/info");
|
||||
return null;
|
||||
}
|
||||
22
apps/web/app/signin/page.tsx
Normal file
22
apps/web/app/signin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
apps/web/auth_failures.log
Normal file
0
apps/web/auth_failures.log
Normal file
17
apps/web/components.json
Normal file
17
apps/web/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/web/components/DemoModeBanner.tsx
Normal file
7
apps/web/components/DemoModeBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/web/components/HoarderIcon.tsx
Normal file
17
apps/web/components/HoarderIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/web/components/dashboard/ChangeLayout.tsx
Normal file
61
apps/web/components/dashboard/ChangeLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
apps/web/components/dashboard/EditableText.tsx
Normal file
147
apps/web/components/dashboard/EditableText.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
11
apps/web/components/dashboard/GlobalActions.tsx
Normal file
11
apps/web/components/dashboard/GlobalActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
apps/web/components/dashboard/UploadDropzone.tsx
Normal file
146
apps/web/components/dashboard/UploadDropzone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
apps/web/components/dashboard/admin/AddUserDialog.tsx
Normal file
213
apps/web/components/dashboard/admin/AddUserDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
apps/web/components/dashboard/admin/AdminActions.tsx
Normal file
134
apps/web/components/dashboard/admin/AdminActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
apps/web/components/dashboard/admin/ChangeRoleDialog.tsx
Normal file
154
apps/web/components/dashboard/admin/ChangeRoleDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
apps/web/components/dashboard/admin/ResetPasswordDialog.tsx
Normal file
145
apps/web/components/dashboard/admin/ResetPasswordDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
apps/web/components/dashboard/admin/ServerStats.tsx
Normal file
136
apps/web/components/dashboard/admin/ServerStats.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
apps/web/components/dashboard/admin/UserList.tsx
Normal file
126
apps/web/components/dashboard/admin/UserList.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/dashboard/bookmarks/AssetCard.tsx
Normal file
75
apps/web/components/dashboard/bookmarks/AssetCard.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
59
apps/web/components/dashboard/bookmarks/BookmarkCard.tsx
Normal file
59
apps/web/components/dashboard/bookmarks/BookmarkCard.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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} />,
|
||||
});
|
||||
}
|
||||
245
apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
Normal file
245
apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
39
apps/web/components/dashboard/bookmarks/Bookmarks.tsx
Normal file
39
apps/web/components/dashboard/bookmarks/Bookmarks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx
Normal file
117
apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx
Normal file
138
apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/web/components/dashboard/bookmarks/BulkTagModal.tsx
Normal file
126
apps/web/components/dashboard/bookmarks/BulkTagModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
apps/web/components/dashboard/bookmarks/EditorCard.tsx
Normal file
279
apps/web/components/dashboard/bookmarks/EditorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/components/dashboard/bookmarks/FooterLinkURL.tsx
Normal file
18
apps/web/components/dashboard/bookmarks/FooterLinkURL.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
apps/web/components/dashboard/bookmarks/LinkCard.tsx
Normal file
92
apps/web/components/dashboard/bookmarks/LinkCard.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
226
apps/web/components/dashboard/bookmarks/ManageListsModal.tsx
Normal file
226
apps/web/components/dashboard/bookmarks/ManageListsModal.tsx
Normal 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} />
|
||||
),
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
43
apps/web/components/dashboard/bookmarks/TagList.tsx
Normal file
43
apps/web/components/dashboard/bookmarks/TagList.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
apps/web/components/dashboard/bookmarks/TagModal.tsx
Normal file
59
apps/web/components/dashboard/bookmarks/TagModal.tsx
Normal 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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
191
apps/web/components/dashboard/bookmarks/TagsEditor.tsx
Normal file
191
apps/web/components/dashboard/bookmarks/TagsEditor.tsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
72
apps/web/components/dashboard/bookmarks/TextCard.tsx
Normal file
72
apps/web/components/dashboard/bookmarks/TextCard.tsx
Normal 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>
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user