diff options
| author | MohamedBassem <me@mbassem.com> | 2024-02-28 20:45:28 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-02-28 20:45:28 +0000 |
| commit | 3208dda3848ad739f54cebf44c423e2b68e85b2d (patch) | |
| tree | 25602c451354a296e8779197fdd42acab7526502 /packages | |
| parent | 7096fb3941579e5c045796361745d597e03ff7fc (diff) | |
| download | karakeep-3208dda3848ad739f54cebf44c423e2b68e85b2d.tar.zst | |
feature: Add support for storing and previewing raw notes
Diffstat (limited to 'packages')
18 files changed, 1129 insertions, 111 deletions
diff --git a/packages/db/drizzle/0001_dapper_trauma.sql b/packages/db/drizzle/0001_dapper_trauma.sql new file mode 100644 index 00000000..6ba5ce5c --- /dev/null +++ b/packages/db/drizzle/0001_dapper_trauma.sql @@ -0,0 +1,5 @@ +CREATE TABLE `bookmarkTexts` ( + `id` text PRIMARY KEY NOT NULL, + `text` text, + FOREIGN KEY (`id`) REFERENCES `bookmarks`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..16915666 --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,652 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "30b63cde-0b6e-42fd-8755-edb258b04dd0", + "prevId": "926e135f-db63-4273-b193-4eb2b5c1784d", + "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": {} + }, + "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": {} + }, + "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 2dbd6ca1..d70c6c1e 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1708710681721, "tag": "0000_luxuriant_johnny_blaze", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1709144284383, + "tag": "0001_dapper_trauma", + "breakpoints": true } ] -} +}
\ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index 27631095..20dee1e9 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -6,7 +6,7 @@ "main": "index.ts", "scripts": { "typecheck": "tsc --noEmit", - "migrate": "ts-node migrate.ts", + "migrate": "tsx migrate.ts", "studio": "drizzle-kit studio" }, "dependencies": { @@ -14,7 +14,8 @@ "@paralleldrive/cuid2": "^2.2.2", "better-sqlite3": "^9.4.3", "dotenv": "^16.4.1", - "drizzle-orm": "^0.29.4" + "drizzle-orm": "^0.29.4", + "tsx": "^4.7.1" }, "devDependencies": { "@tsconfig/node21": "^21.0.1", diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 94467c56..4acd86c4 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -125,6 +125,15 @@ export const bookmarkLinks = sqliteTable("bookmarkLinks", { crawledAt: integer("crawledAt", { mode: "timestamp" }), }); +export const bookmarkTexts = sqliteTable("bookmarkTexts", { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()) + .references(() => bookmarks.id, { onDelete: "cascade" }), + text: text("text"), +}); + export const bookmarkTags = sqliteTable( "bookmarkTags", { @@ -179,6 +188,10 @@ export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ fields: [bookmarks.id], references: [bookmarkLinks.id], }), + text: one(bookmarkTexts, { + fields: [bookmarks.id], + references: [bookmarkTexts.id], + }), tagsOnBookmarks: many(tagsOnBookmarks), })); diff --git a/packages/web/app/dashboard/bookmarks/components/AddLink.tsx b/packages/web/app/dashboard/bookmarks/components/AddBookmark.tsx index 242a52a5..4f0de87a 100644 --- a/packages/web/app/dashboard/bookmarks/components/AddLink.tsx +++ b/packages/web/app/dashboard/bookmarks/components/AddBookmark.tsx @@ -2,25 +2,40 @@ import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Plus } from "lucide-react"; +import { Pencil, Plus } from "lucide-react"; import { useForm, SubmitErrorHandler } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import { useState } from "react"; -const formSchema = z.object({ - url: z.string().url({ message: "The link must be a valid URL" }), -}); +function AddText() { + const [isEditorOpen, setEditorOpen] = useState(false); -export default function AddLink() { + return ( + <div className="flex"> + <BookmarkedTextEditor open={isEditorOpen} setOpen={setEditorOpen} /> + <Button className="m-auto" onClick={() => setEditorOpen(true)}> + <Pencil /> + </Button> + </div> + ); +} + +function AddLink() { + const formSchema = z.object({ + url: z.string().url({ message: "The link must be a valid URL" }), + }); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), }); const invalidateBookmarksCache = api.useUtils().bookmarks.invalidate; - const bookmarkLinkMutator = api.bookmarks.bookmarkLink.useMutation({ + const createBookmarkMutator = api.bookmarks.createBookmark.useMutation({ onSuccess: () => { invalidateBookmarksCache(); }, @@ -41,13 +56,14 @@ export default function AddLink() { return ( <Form {...form}> <form + className="flex-grow" onSubmit={form.handleSubmit( (value) => - bookmarkLinkMutator.mutate({ url: value.url, type: "link" }), + createBookmarkMutator.mutate({ url: value.url, type: "link" }), onError, )} > - <div className="container flex w-full items-center space-x-2 py-4"> + <div className="flex w-full items-center space-x-2 py-4"> <FormField control={form.control} name="url" @@ -61,7 +77,7 @@ export default function AddLink() { ); }} /> - <ActionButton type="submit" loading={bookmarkLinkMutator.isPending}> + <ActionButton type="submit" loading={createBookmarkMutator.isPending}> <Plus /> </ActionButton> </div> @@ -69,3 +85,12 @@ export default function AddLink() { </Form> ); } + +export default function AddBookmark() { + return ( + <div className="container flex gap-2"> + <AddLink /> + <AddText /> + </div> + ); +} diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx index b8f6d8f2..866a4cbd 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx @@ -2,7 +2,7 @@ import { useToast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; -import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZBookmark, ZBookmarkedLink } from "@/lib/types/api/bookmarks"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -14,12 +14,15 @@ import { Archive, Link, MoreHorizontal, + Pencil, RotateCw, Star, Tags, Trash2, } from "lucide-react"; import { useTagModel } from "./TagModal"; +import { useState } from "react"; +import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { toast } = useToast(); @@ -27,6 +30,8 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [_, setTagModalIsOpen, tagModal] = useTagModel(bookmark); + const [isTextEditorOpen, setTextEditorOpen] = useState(false); + const invalidateBookmarksCache = api.useUtils().bookmarks.invalidate; const onError = () => { @@ -72,13 +77,27 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { return ( <> {tagModal} + <BookmarkedTextEditor + bookmark={bookmark} + open={isTextEditorOpen} + setOpen={setTextEditorOpen} + /> <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="ghost"> + <Button + variant="ghost" + className="focus-visible:ring-0 focus-visible:ring-offset-0" + > <MoreHorizontal /> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> + {bookmark.content.type === "text" && ( + <DropdownMenuItem onClick={() => setTextEditorOpen(true)}> + <Pencil className="mr-2 size-4" /> + <span>Edit</span> + </DropdownMenuItem> + )} <DropdownMenuItem onClick={() => updateBookmarkMutator.mutate({ @@ -101,29 +120,36 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { <Archive className="mr-2 size-4" /> <span>{bookmark.archived ? "Un-archive" : "Archive"}</span> </DropdownMenuItem> - <DropdownMenuItem - onClick={() => { - navigator.clipboard.writeText(bookmark.content.url); - toast({ - description: "Link was added to your clipboard!", - }); - }} - > - <Link className="mr-2 size-4" /> - <span>Copy Link</span> - </DropdownMenuItem> + {bookmark.content.type === "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={() => - crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } - > - <RotateCw className="mr-2 size-4" /> - <span>Refresh</span> - </DropdownMenuItem> + + {bookmark.content.type === "link" && ( + <DropdownMenuItem + onClick={() => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + <RotateCw className="mr-2 size-4" /> + <span>Refresh</span> + </DropdownMenuItem> + )} <DropdownMenuItem className="text-destructive" onClick={() => diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx new file mode 100644 index 00000000..e9138e03 --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx @@ -0,0 +1,108 @@ +import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; +import { toast } from "@/components/ui/use-toast"; + +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 == "text" ? bookmark.content.text : "", + ); + + const invalidateAllBookmarksCache = + api.useUtils().bookmarks.getBookmarks.invalidate; + const invalidateOneBookmarksCache = + api.useUtils().bookmarks.getBookmark.invalidate; + + const { mutate: createBookmarkMutator, isPending: isCreationPending } = + api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + invalidateAllBookmarksCache(); + toast({ + description: "Note created!", + }); + setOpen(false); + setNoteText(""); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + const { mutate: updateBookmarkMutator, isPending: isUpdatePending } = + api.bookmarks.updateBookmarkText.useMutation({ + onSuccess: () => { + invalidateOneBookmarksCache({ + bookmarkId: bookmark!.id, + }); + toast({ + description: "Note updated!", + }); + setOpen(false); + setNoteText(""); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + const isPending = isCreationPending || isUpdatePending; + + const onSave = () => { + if (isNewBookmark) { + createBookmarkMutator({ + type: "text", + text: noteText, + }); + } else { + updateBookmarkMutator({ + bookmarkId: bookmark.id, + text: noteText, + }); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle className="pb-4"> + {isNewBookmark ? "New Note" : "Edit Note"} + </DialogTitle> + <Textarea + value={noteText} + onChange={(e) => setNoteText(e.target.value)} + className="h-52 grow" + /> + </DialogHeader> + <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> + ); +} diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx index e07d48b6..c960c8b7 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx @@ -3,11 +3,18 @@ import LinkCard from "./LinkCard"; import { ZBookmark, ZGetBookmarksRequest } from "@/lib/types/api/bookmarks"; import { api } from "@/lib/trpc"; +import TextCard from "./TextCard"; -function renderBookmark(bookmark: ZBookmark) { +function renderBookmark(bookmark: ZBookmark, className: string) { switch (bookmark.content.type) { case "link": - return <LinkCard key={bookmark.id} bookmark={bookmark} />; + return ( + <LinkCard key={bookmark.id} bookmark={bookmark} className={className} /> + ); + case "text": + return ( + <TextCard key={bookmark.id} bookmark={bookmark} className={className} /> + ); } } @@ -25,8 +32,13 @@ export default function BookmarksGrid({ return <p>No bookmarks</p>; } return ( - <div className="container grid grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> - {data.bookmarks.map((b) => renderBookmark(b))} + <div className="container grid grid-flow-row-dense grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> + {data.bookmarks.map((b) => + renderBookmark( + b, + "border-grey-100 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all", + ), + )} </div> ); } diff --git a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx index cd0f128c..73d3f300 100644 --- a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx +++ b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx @@ -1,6 +1,5 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { ImageCard, ImageCardBanner, @@ -13,50 +12,24 @@ import { ZBookmark } from "@/lib/types/api/bookmarks"; import Link from "next/link"; import BookmarkOptions from "./BookmarkOptions"; import { api } from "@/lib/trpc"; -import { Skeleton } from "@/components/ui/skeleton"; import { Star } from "lucide-react"; +import { cn } from "@/lib/utils"; +import TagList from "./TagList"; function isStillCrawling(bookmark: ZBookmark) { return ( + bookmark.content.type == "link" && !bookmark.content.crawledAt && Date.now().valueOf() - bookmark.createdAt.valueOf() < 30 * 1000 ); } -function TagList(bookmark: ZBookmark, stillCrawling: boolean) { - if (stillCrawling) { - return ( - <ImageCardBody className="space-y-2"> - <Skeleton className="h-4 w-full" /> - <Skeleton className="h-4 w-full" /> - <Skeleton className="h-4 w-full" /> - </ImageCardBody> - ); - } - return ( - <ImageCardBody className="flex h-full flex-wrap space-x-1 overflow-hidden"> - {bookmark.tags.map((t) => ( - <Link - className="flex h-full flex-col justify-end" - key={t.id} - href={`/dashboard/tags/${t.name}`} - > - <Badge - variant="default" - className="text-nowrap bg-gray-300 text-gray-500 hover:text-white" - > - #{t.name} - </Badge> - </Link> - ))} - </ImageCardBody> - ); -} - export default function LinkCard({ bookmark: initialData, + className, }: { bookmark: ZBookmark; + className: string; }) { const { data: bookmark } = api.bookmarks.getBookmark.useQuery( { @@ -78,6 +51,9 @@ export default function LinkCard({ }, ); const link = bookmark.content; + if (link.type != "link") { + throw new Error("Unexpected bookmark type"); + } const isCrawling = isStillCrawling(bookmark); const parsedUrl = new URL(link.url); @@ -88,11 +64,7 @@ export default function LinkCard({ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII="; return ( - <ImageCard - className={ - "border-grey-100 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all" - } - > + <ImageCard className={cn(className, "row-span-2")}> {bookmark.favourited && ( <Star className="absolute m-1 size-8 rounded bg-gray-100 p-1" @@ -111,7 +83,9 @@ export default function LinkCard({ </ImageCardTitle> {/* There's a hack here. Every tag has the full hight of the container itself. That why, when we enable flex-wrap, the overflowed don't show up. */} - {TagList(bookmark, isCrawling)} + <ImageCardBody className="flex h-full flex-wrap space-x-1 overflow-hidden"> + <TagList bookmark={bookmark} loading={isCrawling} /> + </ImageCardBody> <ImageCardFooter> <div className="flex justify-between text-gray-500"> <div className="my-auto"> diff --git a/packages/web/app/dashboard/bookmarks/components/TagList.tsx b/packages/web/app/dashboard/bookmarks/components/TagList.tsx new file mode 100644 index 00000000..440452a7 --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/TagList.tsx @@ -0,0 +1,40 @@ +import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ZBookmark } from "@/lib/types/api/bookmarks"; + +export default function TagList({ + bookmark, + loading, +}: { + bookmark: ZBookmark; + loading?: boolean; +}) { + if (loading) { + return ( + <div className="space-y-2"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + </div> + ); + } + return ( + <> + {bookmark.tags.map((t) => ( + <Link + className="flex h-full flex-col justify-end" + key={t.id} + href={`/dashboard/tags/${t.name}`} + > + <Badge + variant="default" + className="text-nowrap bg-gray-300 text-gray-500 hover:text-white" + > + #{t.name} + </Badge> + </Link> + ))} + </> + ); +} diff --git a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx index b0e391b7..3b6a0bd4 100644 --- a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx +++ b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx @@ -4,7 +4,6 @@ import { Dialog, DialogClose, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -168,10 +167,8 @@ export default function TagModal({ <DialogContent> <DialogHeader> <DialogTitle>Edit Tags</DialogTitle> - <DialogDescription> - <TagEditor tags={tags} setTags={setTags} /> - </DialogDescription> </DialogHeader> + <TagEditor tags={tags} setTags={setTags} /> <DialogFooter className="sm:justify-end"> <DialogClose asChild> <Button type="button" variant="secondary"> diff --git a/packages/web/app/dashboard/bookmarks/components/TextCard.tsx b/packages/web/app/dashboard/bookmarks/components/TextCard.tsx new file mode 100644 index 00000000..7ee1a90b --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/TextCard.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { ZBookmark } from "@/lib/types/api/bookmarks"; +import BookmarkOptions from "./BookmarkOptions"; +import { api } from "@/lib/trpc"; +import { Star } from "lucide-react"; +import { cn } from "@/lib/utils"; +import TagList from "./TagList"; + +export default function TextCard({ + bookmark: initialData, + className, +}: { + bookmark: ZBookmark; + className: string; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + }, + ); + const bookmarkedText = bookmark.content; + if (bookmarkedText.type != "text") { + throw new Error("Unexpected bookmark type"); + } + + const numWords = bookmarkedText.text.split(" ").length; + + return ( + <div + className={cn( + className, + cn( + "flex flex-col gap-y-1 overflow-hidden rounded-lg p-2 shadow-md", + numWords > 12 ? "row-span-2 h-96" : "row-span-1 h-40", + ), + )} + > + <p className="grow overflow-hidden text-ellipsis"> + {bookmarkedText.text} + </p> + <div className="flex flex-none flex-wrap gap-1 overflow-hidden"> + <TagList bookmark={bookmark} /> + </div> + <div className="flex w-full justify-between"> + <div> + {bookmark.favourited && ( + <Star + className="my-1 size-8 rounded p-1" + color="#ebb434" + fill="#ebb434" + /> + )} + </div> + <BookmarkOptions bookmark={bookmark} /> + </div> + </div> + ); +} diff --git a/packages/web/app/dashboard/bookmarks/layout.tsx b/packages/web/app/dashboard/bookmarks/layout.tsx index 68704a2b..98aa75a4 100644 --- a/packages/web/app/dashboard/bookmarks/layout.tsx +++ b/packages/web/app/dashboard/bookmarks/layout.tsx @@ -1,5 +1,5 @@ import React from "react"; -import AddLink from "./components/AddLink"; +import AddBookmark from "./components/AddBookmark"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -14,7 +14,7 @@ export default function BookmarksLayout({ return ( <div className="flex h-full flex-col"> <div> - <AddLink /> + <AddBookmark /> </div> <hr /> <div className="my-4 flex-1">{children}</div> diff --git a/packages/web/components/ui/textarea.tsx b/packages/web/components/ui/textarea.tsx new file mode 100644 index 00000000..a0de3371 --- /dev/null +++ b/packages/web/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} + +const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( + ({ className, ...props }, ref) => { + return ( + <textarea + className={cn( + "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + ref={ref} + {...props} + /> + ); + }, +); +Textarea.displayName = "Textarea"; + +export { Textarea }; diff --git a/packages/web/lib/types/api/bookmarks.ts b/packages/web/lib/types/api/bookmarks.ts index 0970a7ed..9b0ae371 100644 --- a/packages/web/lib/types/api/bookmarks.ts +++ b/packages/web/lib/types/api/bookmarks.ts @@ -12,8 +12,15 @@ export const zBookmarkedLinkSchema = z.object({ }); export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>; +export const zBookmarkedTextSchema = z.object({ + type: z.literal("text"), + text: z.string().max(2000), +}); +export type ZBookmarkedText = z.infer<typeof zBookmarkedTextSchema>; + export const zBookmarkContentSchema = z.discriminatedUnion("type", [ zBookmarkedLinkSchema, + zBookmarkedTextSchema, ]); export type ZBookmarkContent = z.infer<typeof zBookmarkContentSchema>; diff --git a/packages/web/server/api/routers/bookmarks.test.ts b/packages/web/server/api/routers/bookmarks.test.ts index 16f82992..603a173e 100644 --- a/packages/web/server/api/routers/bookmarks.test.ts +++ b/packages/web/server/api/routers/bookmarks.test.ts @@ -1,17 +1,18 @@ import { CustomTestContext, defaultBeforeEach } from "@/lib/testUtils"; -import { expect, describe, test, beforeEach } from "vitest"; +import { expect, describe, test, beforeEach, assert } from "vitest"; beforeEach<CustomTestContext>(defaultBeforeEach); describe("Bookmark Routes", () => { test<CustomTestContext>("create bookmark", async ({ apiCallers }) => { const api = apiCallers[0].bookmarks; - const bookmark = await api.bookmarkLink({ + const bookmark = await api.createBookmark({ url: "https://google.com", type: "link", }); const res = await api.getBookmark({ bookmarkId: bookmark.id }); + assert(res.content.type == "link"); expect(res.content.url).toEqual("https://google.com"); expect(res.favourited).toEqual(false); expect(res.archived).toEqual(false); @@ -22,7 +23,7 @@ describe("Bookmark Routes", () => { const api = apiCallers[0].bookmarks; // Create the bookmark - const bookmark = await api.bookmarkLink({ + const bookmark = await api.createBookmark({ url: "https://google.com", type: "link", }); @@ -43,7 +44,7 @@ describe("Bookmark Routes", () => { const api = apiCallers[0].bookmarks; // Create the bookmark - const bookmark = await api.bookmarkLink({ + const bookmark = await api.createBookmark({ url: "https://google.com", type: "link", }); @@ -64,12 +65,12 @@ describe("Bookmark Routes", () => { const emptyBookmarks = await api.getBookmarks({}); expect(emptyBookmarks.bookmarks.length).toEqual(0); - const bookmark1 = await api.bookmarkLink({ + const bookmark1 = await api.createBookmark({ url: "https://google.com", type: "link", }); - const bookmark2 = await api.bookmarkLink({ + const bookmark2 = await api.createBookmark({ url: "https://google2.com", type: "link", }); @@ -113,7 +114,7 @@ describe("Bookmark Routes", () => { test<CustomTestContext>("update tags", async ({ apiCallers }) => { const api = apiCallers[0].bookmarks; - let bookmark = await api.bookmarkLink({ + let bookmark = await api.createBookmark({ url: "https://google.com", type: "link", }); @@ -139,12 +140,29 @@ describe("Bookmark Routes", () => { expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag2", "tag3"]); }); + test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => { + const api = apiCallers[0].bookmarks; + let bookmark = await api.createBookmark({ + text: "HELLO WORLD", + type: "text", + }); + + await api.updateBookmarkText({ + bookmarkId: bookmark.id, + text: "WORLD HELLO", + }); + + bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); + assert(bookmark.content.type == "text"); + expect(bookmark.content.text).toEqual("WORLD HELLO"); + }); + test<CustomTestContext>("privacy", async ({ apiCallers }) => { - const user1Bookmark = await apiCallers[0].bookmarks.bookmarkLink({ + const user1Bookmark = await apiCallers[0].bookmarks.createBookmark({ type: "link", url: "https://google.com", }); - const user2Bookmark = await apiCallers[1].bookmarks.bookmarkLink({ + const user2Bookmark = await apiCallers[1].bookmarks.createBookmark({ type: "link", url: "https://google.com", }); diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts index 64755e4e..bfa0580f 100644 --- a/packages/web/server/api/routers/bookmarks.ts +++ b/packages/web/server/api/routers/bookmarks.ts @@ -13,6 +13,7 @@ import { import { bookmarkLinks, bookmarkTags, + bookmarkTexts, bookmarks, tagsOnBookmarks, } from "@hoarder/db/schema"; @@ -64,6 +65,7 @@ async function dummyDrizzleReturnType() { }, }, link: true, + text: true, }, }); if (!x) { @@ -75,11 +77,13 @@ async function dummyDrizzleReturnType() { function toZodSchema( bookmark: Awaited<ReturnType<typeof dummyDrizzleReturnType>>, ): ZBookmark { - const { tagsOnBookmarks, link, ...rest } = bookmark; + const { tagsOnBookmarks, link, text, ...rest } = bookmark; let content: ZBookmarkContent; if (link) { content = { type: "link", ...link }; + } else if (text) { + content = { type: "text", text: text.text || "" }; } else { throw new Error("Unknown content type"); } @@ -95,12 +99,10 @@ function toZodSchema( } export const bookmarksAppRouter = router({ - bookmarkLink: authedProcedure + createBookmark: authedProcedure .input(zNewBookmarkRequestSchema) .output(zBookmarkSchema) .mutation(async ({ input, ctx }) => { - const { url } = input; - const bookmark = await ctx.db.transaction( async (tx): Promise<ZBookmark> => { const bookmark = ( @@ -112,20 +114,39 @@ export const bookmarksAppRouter = router({ .returning() )[0]; - const link = ( - await tx - .insert(bookmarkLinks) - .values({ - id: bookmark.id, - url, - }) - .returning() - )[0]; + let content: ZBookmarkContent; - const content: ZBookmarkContent = { - type: "link", - ...link, - }; + switch (input.type) { + case "link": { + const link = ( + await tx + .insert(bookmarkLinks) + .values({ + id: bookmark.id, + url: input.url, + }) + .returning() + )[0]; + content = { + type: "link", + ...link, + }; + break; + } + case "text": { + const text = ( + await tx + .insert(bookmarkTexts) + .values({ id: bookmark.id, text: input.text }) + .returning() + )[0]; + content = { + type: "text", + text: text.text || "", + }; + break; + } + } return { tags: [] as ZBookmarkTags[], @@ -170,6 +191,30 @@ export const bookmarksAppRouter = router({ return res[0]; }), + updateBookmarkText: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + text: z.string().max(2000), + }), + ) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + const res = await ctx.db + .update(bookmarkTexts) + .set({ + text: input.text, + }) + .where(and(eq(bookmarkTexts.id, input.bookmarkId))) + .returning(); + if (res.length == 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + }), + deleteBookmark: authedProcedure .input(z.object({ bookmarkId: z.string() })) .use(ensureBookmarkOwnership) @@ -212,6 +257,7 @@ export const bookmarksAppRouter = router({ }, }, link: true, + text: true, }, }); if (!bookmark) { @@ -246,6 +292,7 @@ export const bookmarksAppRouter = router({ }, }, link: true, + text: true, }, }); @@ -271,7 +318,7 @@ export const bookmarksAppRouter = router({ await ctx.db.transaction(async (tx) => { // Detaches if (input.detach.length > 0) { - await ctx.db.delete(tagsOnBookmarks).where( + await tx.delete(tagsOnBookmarks).where( and( eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), inArray( @@ -295,7 +342,7 @@ export const bookmarksAppRouter = router({ })); if (toBeCreatedTags.length > 0) { - await ctx.db + await tx .insert(bookmarkTags) .values(toBeCreatedTags) .onConflictDoNothing() @@ -303,7 +350,7 @@ export const bookmarksAppRouter = router({ } const allIds = ( - await ctx.db.query.bookmarkTags.findMany({ + await tx.query.bookmarkTags.findMany({ where: and( eq(bookmarkTags.userId, ctx.user.id), inArray( @@ -317,7 +364,7 @@ export const bookmarksAppRouter = router({ }) ).map((t) => t.id); - await ctx.db + await tx .insert(tagsOnBookmarks) .values( allIds.map((i) => ({ |
