diff options
Diffstat (limited to 'packages')
21 files changed, 1862 insertions, 5 deletions
diff --git a/packages/db/drizzle/0002_worried_beyonder.sql b/packages/db/drizzle/0002_worried_beyonder.sql new file mode 100644 index 00000000..a3cd989d --- /dev/null +++ b/packages/db/drizzle/0002_worried_beyonder.sql @@ -0,0 +1,19 @@ +CREATE TABLE `bookmarkLists` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `icon` text NOT NULL, + `createdAt` integer NOT NULL, + `userId` text NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `bookmarksInLists` ( + `bookmarkId` text NOT NULL, + `listId` text NOT NULL, + `addedAt` integer, + PRIMARY KEY(`bookmarkId`, `listId`), + FOREIGN KEY (`bookmarkId`) REFERENCES `bookmarks`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`listId`) REFERENCES `bookmarkLists`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `bookmarkLists_name_userId_unique` ON `bookmarkLists` (`name`,`userId`);
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..7e682a9f --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,784 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "e0b3dd84-57d4-4adb-97da-98a6ff5d7d34", + "prevId": "30b63cde-0b6e-42fd-8755-edb258b04dd0", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_name_unique": { + "name": "apiKey_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_name_userId_unique": { + "name": "bookmarkLists_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d70c6c1e..28c22844 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1709144284383, "tag": "0001_dapper_trauma", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1709293861959, + "tag": "0002_worried_beyonder", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index ea78a2b0..3c464d6c 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -172,6 +172,43 @@ export const tagsOnBookmarks = sqliteTable( }), ); +export const bookmarkLists = sqliteTable( + "bookmarkLists", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + name: text("name").notNull(), + icon: text("icon").notNull(), + createdAt: createdAtField(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (bl) => ({ + unq: unique().on(bl.name, bl.userId), + }), +); + +export const bookmarksInLists = sqliteTable( + "bookmarksInLists", + { + bookmarkId: text("bookmarkId") + .notNull() + .references(() => bookmarks.id, { onDelete: "cascade" }), + listId: text("listId") + .notNull() + .references(() => bookmarkLists.id, { onDelete: "cascade" }), + addedAt: integer("addedAt", { mode: "timestamp" }).$defaultFn( + () => new Date(), + ), + }, + (tb) => ({ + pk: primaryKey({ columns: [tb.bookmarkId, tb.listId] }), + }), +); + // Relations export const userRelations = relations(users, ({ many }) => ({ @@ -193,6 +230,7 @@ export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ references: [bookmarkTexts.id], }), tagsOnBookmarks: many(tagsOnBookmarks), + bookmarksInLists: many(bookmarksInLists), })); export const bookmarkTagsRelations = relations( @@ -226,3 +264,28 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({ references: [users.id], }), })); + +export const bookmarkListsRelations = relations( + bookmarkLists, + ({ one, many }) => ({ + bookmarksInLists: many(bookmarksInLists), + user: one(users, { + fields: [bookmarkLists.userId], + references: [users.id], + }), + }), +); + +export const bookmarksInListsRelations = relations( + bookmarksInLists, + ({ one }) => ({ + bookmark: one(bookmarks, { + fields: [bookmarksInLists.bookmarkId], + references: [bookmarks.id], + }), + list: one(bookmarkLists, { + fields: [bookmarksInLists.listId], + references: [bookmarkLists.id], + }), + }), +); diff --git a/packages/web/app/dashboard/bookmarks/components/AddToListModal.tsx b/packages/web/app/dashboard/bookmarks/components/AddToListModal.tsx new file mode 100644 index 00000000..36e32ab7 --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/AddToListModal.tsx @@ -0,0 +1,171 @@ +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 { api } from "@/lib/trpc"; +import { useState } from "react"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import LoadingSpinner from "@/components/ui/spinner"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +export default function AddToListModal({ + 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), + }); + + const { data: lists, isPending: isFetchingListsPending } = + api.lists.list.useQuery(); + + const listInvalidationFunction = api.useUtils().lists.get.invalidate; + const bookmarksInvalidationFunction = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { mutate: addToList, isPending: isAddingToListPending } = + api.lists.addToList.useMutation({ + onSuccess: (_resp, req) => { + toast({ + description: "List has been updated!", + }); + listInvalidationFunction({ listId: req.listId }); + bookmarksInvalidationFunction(); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + toast({ + variant: "destructive", + description: e.message, + }); + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + const isPending = isFetchingListsPending || isAddingToListPending; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <Form {...form}> + <form + onSubmit={form.handleSubmit((value) => { + addToList({ + bookmarkId: bookmarkId, + listId: value.listId, + }); + })} + > + <DialogHeader> + <DialogTitle>Add to List</DialogTitle> + </DialogHeader> + + <div className="py-4"> + {lists ? ( + <FormField + control={form.control} + name="listId" + render={({ field }) => { + return ( + <FormItem> + <FormControl> + <Select onValueChange={field.onChange}> + <SelectTrigger className="w-full"> + <SelectValue placeholder="Select a list" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {lists && + lists.lists.map((l) => ( + <SelectItem key={l.id} value={l.id}> + {l.icon} {l.name} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + ) : ( + <LoadingSpinner /> + )} + </div> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isAddingToListPending} + disabled={isPending} + > + Add + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} + +export function useAddToListModal(bookmarkId: string) { + const [open, setOpen] = useState(false); + + return [ + open, + setOpen, + <AddToListModal + key={bookmarkId} + bookmarkId={bookmarkId} + open={open} + setOpen={setOpen} + />, + ] as const; +} diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx index 3a2b6b35..d4447f29 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx @@ -13,6 +13,7 @@ import { import { Archive, Link, + List, MoreHorizontal, Pencil, RotateCw, @@ -23,12 +24,16 @@ import { import { useTagModel } from "./TagModal"; import { useState } from "react"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import { useAddToListModal } from "./AddToListModal"; export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { toast } = useToast(); const linkId = bookmark.id; const [_, setTagModalIsOpen, tagModal] = useTagModel(bookmark); + const [_2, setAddToListModalOpen, addToListModal] = useAddToListModal( + bookmark.id, + ); const [isTextEditorOpen, setTextEditorOpen] = useState(false); @@ -77,6 +82,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { return ( <> {tagModal} + {addToListModal} <BookmarkedTextEditor bookmark={bookmark} open={isTextEditorOpen} @@ -140,6 +146,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { <span>Edit Tags</span> </DropdownMenuItem> + <DropdownMenuItem onClick={() => setAddToListModalOpen(true)}> + <List className="mr-2 size-4" /> + <span>Add to List</span> + </DropdownMenuItem> + {bookmark.content.type === "link" && ( <DropdownMenuItem onClick={() => diff --git a/packages/web/app/dashboard/components/AllLists.tsx b/packages/web/app/dashboard/components/AllLists.tsx new file mode 100644 index 00000000..6b5ca3b5 --- /dev/null +++ b/packages/web/app/dashboard/components/AllLists.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { api } from "@/lib/trpc"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import SidebarItem from "./SidebarItem"; +import LoadingSpinner from "@/components/ui/spinner"; +import NewListModal, { useNewListModal } from "./NewListModal"; +import { Plus } from "lucide-react"; +import Link from "next/link"; + +export default function AllLists() { + const { data: lists } = api.lists.list.useQuery(); + + const { setOpen } = useNewListModal(); + + return ( + <ul className="max-h-full gap-2 overflow-scroll text-sm font-medium"> + <NewListModal /> + <li className="flex justify-between py-2 font-bold"> + <p>Lists</p> + <Link href="#" onClick={() => setOpen(true)}> + <Plus /> + </Link> + </li> + {lists && lists.lists.length == 0 && <li>No lists</li>} + {lists ? ( + lists.lists.map((l) => ( + <SidebarItem + key={l.id} + logo={<span className="text-lg"> {l.icon}</span>} + name={l.name} + path={`/dashboard/lists/${l.id}`} + className="py-0.5" + /> + )) + ) : ( + <LoadingSpinner /> + )} + </ul> + ); +} diff --git a/packages/web/app/dashboard/components/NewListModal.tsx b/packages/web/app/dashboard/components/NewListModal.tsx new file mode 100644 index 00000000..17b72576 --- /dev/null +++ b/packages/web/app/dashboard/components/NewListModal.tsx @@ -0,0 +1,170 @@ +"use client"; + +import data from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +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, + FormMessage, +} from "@/components/ui/form"; + +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/ui/input"; + +import { create } from "zustand"; + +export const useNewListModal = create<{ + open: boolean; + setOpen: (v: boolean) => void; +}>((set) => ({ + open: false, + setOpen: (open: boolean) => set(() => ({ open })), +})); + +export default function NewListModal() { + const { open, setOpen } = useNewListModal(); + + const formSchema = z.object({ + name: z.string(), + icon: z.string(), + }); + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + icon: "💡", + }, + }); + + const listsInvalidationFunction = api.useUtils().lists.list.invalidate; + + const { mutate: createList, isPending } = api.lists.create.useMutation({ + onSuccess: () => { + toast({ + description: "List has been created!", + }); + listsInvalidationFunction(); + setOpen(false); + }, + 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={(s) => { + form.reset(); + setOpen(s); + }} + > + <DialogContent> + <Form {...form}> + <form + onSubmit={form.handleSubmit((value) => { + createList(value); + })} + > + <DialogHeader> + <DialogTitle>Create List</DialogTitle> + </DialogHeader> + <div className="flex w-full gap-2 py-4"> + <FormField + control={form.control} + name="icon" + render={({ field }) => { + return ( + <FormItem> + <FormControl> + <Popover> + <PopoverTrigger className="border-input h-full rounded border px-2 text-2xl"> + {field.value} + </PopoverTrigger> + <PopoverContent> + <Picker + data={data} + onEmojiSelect={(e: { native: string }) => + field.onChange(e.native) + } + /> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="grow"> + <FormControl> + <Input + type="text" + className="w-full" + placeholder="List Name" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + </div> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton type="submit" loading={isPending}> + Create + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx index b8b7fc56..7eea6b6d 100644 --- a/packages/web/app/dashboard/components/Sidebar.tsx +++ b/packages/web/app/dashboard/components/Sidebar.tsx @@ -4,6 +4,8 @@ import SidebarItem from "./SidebarItem"; import { getServerAuthSession } from "@/server/auth"; import Link from "next/link"; import SidebarProfileOptions from "./SidebarProfileOptions"; +import { Separator } from "@/components/ui/separator"; +import AllLists from "./AllLists"; export default async function Sidebar() { const session = await getServerAuthSession(); @@ -12,16 +14,16 @@ export default async function Sidebar() { } return ( - <aside className="flex h-full w-60 flex-col border-r p-4"> + <aside className="flex h-screen w-60 flex-col gap-5 border-r p-4"> <Link href={"/dashboard/bookmarks"}> - <div className="mb-5 flex items-center rounded-lg px-1 text-slate-900"> + <div className="flex items-center rounded-lg px-1 text-slate-900"> <PackageOpen /> <span className="ml-2 text-base font-semibold">Hoarder</span> </div> </Link> <hr /> <div> - <ul className="mt-5 space-y-2 text-sm font-medium"> + <ul className="space-y-2 text-sm font-medium"> <SidebarItem logo={<Home />} name="Home" @@ -45,6 +47,8 @@ export default async function Sidebar() { /> </ul> </div> + <Separator /> + <AllLists /> <div className="mt-auto flex justify-between justify-self-end"> <div className="my-auto"> {session.user.name} </div> <SidebarProfileOptions /> diff --git a/packages/web/app/dashboard/components/SidebarItem.tsx b/packages/web/app/dashboard/components/SidebarItem.tsx index 74d20bc0..856bdffd 100644 --- a/packages/web/app/dashboard/components/SidebarItem.tsx +++ b/packages/web/app/dashboard/components/SidebarItem.tsx @@ -8,20 +8,23 @@ export default function SidebarItem({ name, logo, path, + className, }: { name: string; logo: React.ReactNode; path: string; + className?: string; }) { const currentPath = usePathname(); return ( <li className={cn( - "rounded-lg hover:bg-slate-100", + "rounded-lg px-3 py-2 hover:bg-slate-100", path == currentPath ? "bg-gray-50" : "", + className, )} > - <Link href={path} className="flex w-full space-x-2 px-3 py-2"> + <Link href={path} className="flex w-full gap-x-2"> {logo} <span className="my-auto"> {name} </span> </Link> diff --git a/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx new file mode 100644 index 00000000..8961b2d0 --- /dev/null +++ b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx @@ -0,0 +1,76 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Trash } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ActionButton } from "@/components/ui/action-button"; +import { useState } from "react"; +import { ZBookmarkList } from "@/lib/types/api/lists"; + +export default function DeleteListButton({ list }: { list: ZBookmarkList }) { + const [isDialogOpen, setDialogOpen] = useState(false); + + const router = useRouter(); + + const listsInvalidationFunction = api.useUtils().lists.list.invalidate; + const { mutate: deleteList, isPending } = api.lists.delete.useMutation({ + onSuccess: () => { + listsInvalidationFunction(); + toast({ + description: `List "${list.icon} ${list.name}" is deleted!`, + }); + router.push("/"); + }, + onError: () => { + toast({ + variant: "destructive", + description: `Something went wrong`, + }); + }, + }); + return ( + <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button className="mt-auto flex gap-2" variant="destructive"> + <Trash className="size-5" /> + <span className="hidden md:block">Delete List</span> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle> + Delete {list.icon} {list.name}? + </DialogTitle> + </DialogHeader> + <span> + Are you sure you want to delete {list.icon} {list.name}? + </span> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + variant="destructive" + loading={isPending} + onClick={() => deleteList({ listId: list.id })} + > + Delete + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx new file mode 100644 index 00000000..c3d49b6a --- /dev/null +++ b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx @@ -0,0 +1,35 @@ +"use client"; + +import BookmarksGrid from "@/app/dashboard/bookmarks/components/BookmarksGrid"; +import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZBookmarkListWithBookmarks } from "@/lib/types/api/lists"; +import { api } from "@/lib/trpc"; +import DeleteListButton from "./DeleteListButton"; + +export default function ListView({ + bookmarks, + list: initialData, +}: { + list: ZBookmarkListWithBookmarks; + bookmarks: ZBookmark[]; +}) { + const { data } = api.lists.get.useQuery( + { listId: initialData.id }, + { + initialData, + }, + ); + + return ( + <div className="container flex flex-col gap-3"> + <div className="flex justify-between"> + <span className="pt-4 text-2xl"> + {data.icon} {data.name} + </span> + <DeleteListButton list={data} /> + </div> + <hr /> + <BookmarksGrid query={{ ids: data.bookmarks }} bookmarks={bookmarks} /> + </div> + ); +} diff --git a/packages/web/app/dashboard/lists/[listId]/page.tsx b/packages/web/app/dashboard/lists/[listId]/page.tsx new file mode 100644 index 00000000..b8ca79c3 --- /dev/null +++ b/packages/web/app/dashboard/lists/[listId]/page.tsx @@ -0,0 +1,32 @@ +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; +import { TRPCError } from "@trpc/server"; +import { notFound, redirect } from "next/navigation"; +import ListView from "./components/ListView"; + +export default async function ListPage({ + params, +}: { + params: { listId: string }; +}) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + 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; + } + + const bookmarks = await api.bookmarks.getBookmarks({ ids: list.bookmarks }); + + return <ListView list={list} bookmarks={bookmarks.bookmarks} />; +} diff --git a/packages/web/components/ui/popover.tsx b/packages/web/components/ui/popover.tsx new file mode 100644 index 00000000..a361ba7d --- /dev/null +++ b/packages/web/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef<typeof PopoverPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none", + className, + )} + {...props} + /> + </PopoverPrimitive.Portal> +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/packages/web/components/ui/scroll-area.tsx b/packages/web/components/ui/scroll-area.tsx new file mode 100644 index 00000000..32cb6022 --- /dev/null +++ b/packages/web/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> +>(({ className, orientation = "vertical", ...props }, ref) => ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className, + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/packages/web/components/ui/select.tsx b/packages/web/components/ui/select.tsx new file mode 100644 index 00000000..efd4ff1e --- /dev/null +++ b/packages/web/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronUp className="size-4" /> + </SelectPrimitive.ScrollUpButton> +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronDown className="size-4" /> + </SelectPrimitive.ScrollDownButton> +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("bg-muted -mx-1 my-1 h-px", className)} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/packages/web/lib/types/api/lists.ts b/packages/web/lib/types/api/lists.ts new file mode 100644 index 00000000..4b0ccaca --- /dev/null +++ b/packages/web/lib/types/api/lists.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const zBookmarkListSchema = z.object({ + id: z.string(), + name: z.string(), + icon: z.string(), +}); + +export const zBookmarkListWithBookmarksSchema = zBookmarkListSchema.merge( + z.object({ + bookmarks: z.array(z.string()), + }), +); + +export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>; +export type ZBookmarkListWithBookmarks = z.infer< + typeof zBookmarkListWithBookmarksSchema +>; diff --git a/packages/web/package.json b/packages/web/package.json index 0a4a1992..7687704f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@auth/drizzle-adapter": "^0.7.0", + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", "@hoarder/db": "0.1.0", "@hoarder/shared": "0.1.0", "@hookform/resolvers": "^3.3.4", @@ -19,6 +21,9 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", @@ -53,6 +58,7 @@ "devDependencies": { "@tailwindcss/typography": "^0.5.10", "@types/bcrypt": "^5.0.2", + "@types/emoji-mart": "^3.0.14", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts index b958ef8f..6a1b05e9 100644 --- a/packages/web/server/api/routers/_app.ts +++ b/packages/web/server/api/routers/_app.ts @@ -1,11 +1,13 @@ import { router } from "../trpc"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; +import { listsAppRouter } from "./lists"; import { usersAppRouter } from "./users"; export const appRouter = router({ bookmarks: bookmarksAppRouter, apiKeys: apiKeysAppRouter, users: usersAppRouter, + lists: listsAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts index 4e98eb2f..8b59f1ef 100644 --- a/packages/web/server/api/routers/bookmarks.ts +++ b/packages/web/server/api/routers/bookmarks.ts @@ -284,6 +284,9 @@ export const bookmarksAppRouter = router({ .input(zGetBookmarksRequestSchema) .output(zGetBookmarksResponseSchema) .query(async ({ input, ctx }) => { + if (input.ids && input.ids.length == 0) { + return { bookmarks: [] }; + } const results = await ctx.db.query.bookmarks.findMany({ where: and( eq(bookmarks.userId, ctx.user.id), diff --git a/packages/web/server/api/routers/lists.ts b/packages/web/server/api/routers/lists.ts new file mode 100644 index 00000000..7bf5eed5 --- /dev/null +++ b/packages/web/server/api/routers/lists.ts @@ -0,0 +1,173 @@ +import { Context, authedProcedure, router } from "../trpc"; +import { SqliteError } from "@hoarder/db"; +import { z } from "zod"; +import { TRPCError, experimental_trpcMiddleware } from "@trpc/server"; +import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema"; +import { and, eq } from "drizzle-orm"; +import { zBookmarkListSchema } from "@/lib/types/api/lists"; + +const ensureListOwnership = experimental_trpcMiddleware<{ + ctx: Context; + input: { listId: string }; +}>().create(async (opts) => { + const list = await opts.ctx.db.query.bookmarkLists.findFirst({ + where: eq(bookmarkLists.id, opts.input.listId), + columns: { + userId: true, + }, + }); + if (!opts.ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User is not authorized", + }); + } + if (!list) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "List not found", + }); + } + if (list.userId != opts.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + + return opts.next(); +}); + +export const listsAppRouter = router({ + create: authedProcedure + .input( + z.object({ + name: z.string().min(1).max(20), + icon: z.string(), + }), + ) + .output(zBookmarkListSchema) + .mutation(async ({ input, ctx }) => { + try { + const result = await ctx.db + .insert(bookmarkLists) + .values({ + name: input.name, + icon: input.icon, + userId: ctx.user.id, + }) + .returning(); + return result[0]; + } catch (e) { + if (e instanceof SqliteError) { + if (e.code == "SQLITE_CONSTRAINT_UNIQUE") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "List already exists", + }); + } + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong", + }); + } + }), + delete: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .use(ensureListOwnership) + .mutation(async ({ input, ctx }) => { + const res = await ctx.db + .delete(bookmarkLists) + .where( + and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + ); + if (res.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + }), + addToList: authedProcedure + .input( + z.object({ + listId: z.string(), + bookmarkId: z.string(), + }), + ) + .use(ensureListOwnership) + .mutation(async ({ input, ctx }) => { + try { + await ctx.db.insert(bookmarksInLists).values({ + listId: input.listId, + bookmarkId: input.bookmarkId, + }); + } catch (e) { + if (e instanceof SqliteError) { + if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Bookmark already in the list", + }); + } + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong", + }); + } + }), + get: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .output( + zBookmarkListSchema.merge( + z.object({ + bookmarks: z.array(z.string()), + }), + ), + ) + .use(ensureListOwnership) + .query(async ({ input, ctx }) => { + const res = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + with: { + bookmarksInLists: true, + }, + }); + if (!res) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + return { + id: res.id, + name: res.name, + icon: res.icon, + bookmarks: res.bookmarksInLists.map((b) => b.bookmarkId), + }; + }), + list: authedProcedure + .output( + z.object({ + lists: z.array(zBookmarkListSchema), + }), + ) + .query(async ({ ctx }) => { + const lists = await ctx.db.query.bookmarkLists.findMany({ + where: and(eq(bookmarkLists.userId, ctx.user.id)), + }); + + return { lists }; + }), +}); |
