diff options
Diffstat (limited to 'apps')
31 files changed, 726 insertions, 147 deletions
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index b6ac2549..c696f95c 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "format": "prettier .", + "format:fix": "prettier . --write", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint . --ext ts,tsx --fix", "preview": "vite preview", "typecheck": "tsc --noEmit" }, diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json index f77e7f00..b9bc1c46 100644 --- a/apps/browser-extension/tsconfig.json +++ b/apps/browser-extension/tsconfig.json @@ -16,7 +16,7 @@ "jsx": "react-jsx", "strict": true, - "noUnusedLocals": true, + "noUnusedLocals": false, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, diff --git a/apps/cli/package.json b/apps/cli/package.json index 383b8f40..2a4fb987 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -33,7 +33,9 @@ "build": "vite build", "run": "tsx src/index.ts", "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", "typecheck": "tsc --noEmit" }, "repository": { diff --git a/apps/cli/src/commands/bookmarks.ts b/apps/cli/src/commands/bookmarks.ts index 1537740b..b6f7b2d3 100644 --- a/apps/cli/src/commands/bookmarks.ts +++ b/apps/cli/src/commands/bookmarks.ts @@ -73,6 +73,10 @@ bookmarkCmd collect<string>, [], ) + .option( + "--title <title>", + "if set, this will be used as the bookmark's title", + ) .action(async (opts) => { const api = getAPIClient(); @@ -81,7 +85,7 @@ bookmarkCmd const promises = [ ...opts.link.map((url) => api.bookmarks.createBookmark - .mutate({ type: BookmarkTypes.LINK, url }) + .mutate({ type: BookmarkTypes.LINK, url, title: opts.title }) .then((bookmark: ZBookmark) => { results.push(normalizeBookmark(bookmark)); }) @@ -89,7 +93,7 @@ bookmarkCmd ), ...opts.note.map((text) => api.bookmarks.createBookmark - .mutate({ type: BookmarkTypes.TEXT, text }) + .mutate({ type: BookmarkTypes.TEXT, text, title: opts.title }) .then((bookmark: ZBookmark) => { results.push(normalizeBookmark(bookmark)); }) @@ -105,7 +109,7 @@ bookmarkCmd const text = fs.readFileSync(0, "utf-8"); promises.push( api.bookmarks.createBookmark - .mutate({ type: BookmarkTypes.TEXT, text }) + .mutate({ type: BookmarkTypes.TEXT, text, title: opts.title }) .then((bookmark: ZBookmark) => { results.push(normalizeBookmark(bookmark)); }) diff --git a/apps/cli/src/commands/lists.ts b/apps/cli/src/commands/lists.ts index 4b157cdf..57b6d948 100644 --- a/apps/cli/src/commands/lists.ts +++ b/apps/cli/src/commands/lists.ts @@ -89,9 +89,17 @@ listsCmd .action(async (opts) => { const api = getAPIClient(); try { - const results = await api.lists.get.query({ listId: opts.list }); + let resp = await api.bookmarks.getBookmarks.query({ listId: opts.list }); + let results: string[] = resp.bookmarks.map((b) => b.id); + while (resp.nextCursor) { + resp = await api.bookmarks.getBookmarks.query({ + listId: opts.list, + cursor: resp.nextCursor, + }); + results = [...results, ...resp.bookmarks.map((b) => b.id)]; + } - printObject(results.bookmarks); + printObject(results); } catch (error) { printErrorMessageWithReason( "Failed to get the ids of the bookmarks in the list", diff --git a/apps/landing/app/page.tsx b/apps/landing/app/page.tsx index b2c9b414..db5fbd64 100644 --- a/apps/landing/app/page.tsx +++ b/apps/landing/app/page.tsx @@ -107,7 +107,7 @@ function NavBar() { href={GITHUB_LINK} className="flex justify-center gap-2 text-center" > - Github + GitHub </Link> <Link href={DEMO_LINK} @@ -162,7 +162,7 @@ function Hero() { buttonVariants({ variant: "outline", size: "lg" }), )} > - <Github /> Github + <Github /> GitHub </Link> </div> </div> @@ -234,7 +234,7 @@ function Footer() { href={GITHUB_LINK} className="flex justify-center gap-2 text-center" > - Github + GitHub </Link> </div> </div> diff --git a/apps/landing/package.json b/apps/landing/package.json index ca5c7a98..143b8ea7 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -11,7 +11,9 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "format": "prettier --check . --ignore-path ../../.gitignore" + "format": "prettier --check . --ignore-path ../../.gitignore", + "format:fix": "prettier --write . --ignore-path ../../.gitignore", + "lint:fix": "next lint --fix" }, "dependencies": { "@radix-ui/react-slot": "^1.0.2", @@ -19,7 +21,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.330.0", - "next": "14.2.15", + "next": "14.2.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-select": "^5.8.0", diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx index 13d639c9..ce294a6f 100644 --- a/apps/mobile/components/bookmarks/BookmarkCard.tsx +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -1,5 +1,6 @@ import { ActivityIndicator, + Alert, Image, Platform, Pressable, @@ -70,6 +71,20 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { onError, }); + const deleteBookmarkAlert = () => + Alert.alert( + "Delete bookmark?", + "Are you sure you want to delete this bookmark?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + onPress: () => deleteBookmark({ bookmarkId: bookmark.id }), + style: "destructive", + }, + ], + ); + return ( <View className="flex flex-row gap-4"> {(isArchivePending || isDeletionPending) && <ActivityIndicator />} @@ -93,9 +108,7 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { onPressAction={({ nativeEvent }) => { Haptics.selectionAsync(); if (nativeEvent.event === "delete") { - deleteBookmark({ - bookmarkId: bookmark.id, - }); + deleteBookmarkAlert(); } else if (nativeEvent.event === "archive") { archiveBookmark({ bookmarkId: bookmark.id, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6f21820c..2634bdd7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -9,7 +9,9 @@ "ios": "expo run:ios", "web": "expo start --web", "format": "prettier .", + "format:fix": "prettier . --write", "lint": "eslint .", + "lint:fix": "eslint . --fix", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts index 3bff79ba..66ec6754 100644 --- a/apps/web/app/api/assets/[assetId]/route.ts +++ b/apps/web/app/api/assets/[assetId]/route.ts @@ -2,7 +2,11 @@ import { createContextFromRequest } from "@/server/api/client"; import { and, eq } from "drizzle-orm"; import { assets } from "@hoarder/db/schema"; -import { readAsset } from "@hoarder/shared/assetdb"; +import { + createAssetReadStream, + getAssetSize, + readAssetMetadata, +} from "@hoarder/shared/assetdb"; export const dynamic = "force-dynamic"; export async function GET( @@ -22,35 +26,60 @@ export async function GET( return Response.json({ error: "Asset not found" }, { status: 404 }); } - const { asset, metadata } = await readAsset({ - userId: ctx.user.id, - assetId: params.assetId, - }); + const [metadata, size] = await Promise.all([ + readAssetMetadata({ + userId: ctx.user.id, + assetId: params.assetId, + }), + + getAssetSize({ + userId: ctx.user.id, + assetId: params.assetId, + }), + ]); const range = request.headers.get("Range"); if (range) { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : asset.length - 1; + const end = parts[1] ? parseInt(parts[1], 10) : size - 1; - // TODO: Don't read the whole asset into memory in the first place - const chunk = asset.subarray(start, end + 1); - return new Response(chunk, { - status: 206, // Partial Content - headers: { - "Content-Range": `bytes ${start}-${end}/${asset.length}`, - "Accept-Ranges": "bytes", - "Content-Length": chunk.length.toString(), - "Content-type": metadata.contentType, - }, + const stream = createAssetReadStream({ + userId: ctx.user.id, + assetId: params.assetId, + start, + end, }); - } else { - return new Response(asset, { - status: 200, - headers: { - "Content-Length": asset.length.toString(), - "Content-type": metadata.contentType, + + return new Response( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + stream as any, + { + status: 206, // Partial Content + headers: { + "Content-Range": `bytes ${start}-${end}/${size}`, + "Accept-Ranges": "bytes", + "Content-Length": (end - start + 1).toString(), + "Content-type": metadata.contentType, + }, }, + ); + } else { + const stream = createAssetReadStream({ + userId: ctx.user.id, + assetId: params.assetId, }); + + return new Response( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + stream as any, + { + status: 200, + headers: { + "Content-Length": size.toString(), + "Content-type": metadata.contentType, + }, + }, + ); } } diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts new file mode 100644 index 00000000..3fc50801 --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +export const PUT = ( + req: NextRequest, + params: { params: { bookmarkId: string; assetId: string } }, +) => + buildHandler({ + req, + bodySchema: z.object({ assetId: z.string() }), + handler: async ({ api, body }) => { + await api.bookmarks.replaceAsset({ + bookmarkId: params.params.bookmarkId, + oldAssetId: params.params.assetId, + newAssetId: body!.assetId, + }); + return { status: 204 }; + }, + }); + +export const DELETE = ( + req: NextRequest, + params: { params: { bookmarkId: string; assetId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + await api.bookmarks.detachAsset({ + bookmarkId: params.params.bookmarkId, + assetId: params.params.assetId, + }); + return { status: 204 }; + }, + }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts new file mode 100644 index 00000000..e5284a39 --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; +import { buildHandler } from "@/app/api/v1/utils/handler"; + +import { zAssetSchema } from "@hoarder/shared/types/bookmarks"; + +export const dynamic = "force-dynamic"; + +export const GET = ( + req: NextRequest, + params: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + handler: async ({ api }) => { + const resp = await api.bookmarks.getBookmark({ + bookmarkId: params.params.bookmarkId, + }); + return { status: 200, resp: { assets: resp.assets } }; + }, + }); + +export const POST = ( + req: NextRequest, + params: { params: { bookmarkId: string } }, +) => + buildHandler({ + req, + bodySchema: zAssetSchema, + handler: async ({ api, body }) => { + const asset = await api.bookmarks.attachAsset({ + bookmarkId: params.params.bookmarkId, + asset: body!, + }); + return { status: 201, resp: asset }; + }, + }); diff --git a/apps/web/app/api/v1/bookmarks/search/route.ts b/apps/web/app/api/v1/bookmarks/search/route.ts new file mode 100644 index 00000000..f0c5417a --- /dev/null +++ b/apps/web/app/api/v1/bookmarks/search/route.ts @@ -0,0 +1,39 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; + +import { buildHandler } from "../../utils/handler"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest) => + buildHandler({ + req, + searchParamsSchema: z.object({ + q: z.string(), + limit: z.coerce.number().optional(), + cursor: z + .string() + // Search cursor V1 is just a number + .pipe(z.coerce.number()) + .transform((val) => { + return { ver: 1 as const, offset: val }; + }) + .optional(), + }), + handler: async ({ api, searchParams }) => { + const bookmarks = await api.bookmarks.searchBookmarks({ + text: searchParams.q, + cursor: searchParams.cursor, + limit: searchParams.limit, + }); + return { + status: 200, + resp: { + bookmarks: bookmarks.bookmarks, + nextCursor: bookmarks.nextCursor + ? `${bookmarks.nextCursor.offset}` + : null, + }, + }; + }, + }); diff --git a/apps/web/app/api/v1/highlights/route.ts b/apps/web/app/api/v1/highlights/route.ts index ebb96bae..a324d498 100644 --- a/apps/web/app/api/v1/highlights/route.ts +++ b/apps/web/app/api/v1/highlights/route.ts @@ -25,6 +25,6 @@ export const POST = (req: NextRequest) => bodySchema: zNewHighlightSchema, handler: async ({ body, api }) => { const resp = await api.highlights.create(body!); - return { status: 200, resp }; + return { status: 201, resp }; }, }); diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts index 69c99fda..3fd0a32d 100644 --- a/apps/web/app/api/v1/lists/[listId]/route.ts +++ b/apps/web/app/api/v1/lists/[listId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { buildHandler } from "@/app/api/v1/utils/handler"; -import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists"; +import { zEditBookmarkListSchema } from "@hoarder/shared/types/lists"; export const dynamic = "force-dynamic"; @@ -28,11 +28,11 @@ export const PATCH = ( ) => buildHandler({ req, - bodySchema: zNewBookmarkListSchema.partial(), + bodySchema: zEditBookmarkListSchema.omit({ listId: true }), handler: async ({ api, body }) => { const list = await api.lists.edit({ - listId: params.listId, ...body!, + listId: params.listId, }); return { status: 200, resp: list }; }, diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx index f8c5e0b6..159730a1 100644 --- a/apps/web/app/dashboard/lists/[listId]/page.tsx +++ b/apps/web/app/dashboard/lists/[listId]/page.tsx @@ -4,6 +4,8 @@ import ListHeader from "@/components/dashboard/lists/ListHeader"; import { api } from "@/server/api/client"; import { TRPCError } from "@trpc/server"; +import { BookmarkListContextProvider } from "@hoarder/shared-react/hooks/bookmark-list-context"; + export default async function ListPage({ params, }: { @@ -22,11 +24,13 @@ export default async function ListPage({ } return ( - <Bookmarks - query={{ listId: list.id }} - showDivider={true} - showEditorCard={true} - header={<ListHeader initialData={list} />} - /> + <BookmarkListContextProvider list={list}> + <Bookmarks + query={{ listId: list.id }} + showDivider={true} + showEditorCard={list.type === "manual"} + header={<ListHeader initialData={list} />} + /> + </BookmarkListContextProvider> ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index 1df0c197..a2323987 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -30,6 +30,13 @@ interface Props { wrapTags: boolean; } +function BookmarkFormattedCreatedAt({ bookmark }: { bookmark: ZBookmark }) { + const createdAt = dayjs(bookmark.createdAt); + const oneYearAgo = dayjs().subtract(1, "year"); + const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY"; + return createdAt.format(formatString); +} + function BottomRow({ footer, bookmark, @@ -45,7 +52,7 @@ function BottomRow({ href={`/dashboard/preview/${bookmark.id}`} suppressHydrationWarning > - {dayjs(bookmark.createdAt).format("MMM DD")} + <BookmarkFormattedCreatedAt bookmark={bookmark} /> </Link> </div> <BookmarkActionBar bookmark={bookmark} /> @@ -232,7 +239,7 @@ function CompactView({ bookmark, title, footer, className }: Props) { suppressHydrationWarning className="shrink-0 gap-2 text-gray-500" > - {dayjs(bookmark.createdAt).format("MMM DD")} + <BookmarkFormattedCreatedAt bookmark={bookmark} /> </Link> </div> <BookmarkActionBar bookmark={bookmark} /> diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx index 74eb0868..60a6d634 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -30,7 +30,7 @@ export function BookmarkMarkdownComponent({ });
};
return (
- <div className="h-full overflow-hidden">
+ <div className="h-full">
{readOnly ? (
<MarkdownReadonly>{bookmark.content.text}</MarkdownReadonly>
) : (
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 8dfb96fd..c37c6417 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -28,15 +28,16 @@ import type { ZBookmarkedLink, } from "@hoarder/shared/types/bookmarks"; import { - useDeleteBookmark, useRecrawlBookmark, useUpdateBookmark, } from "@hoarder/shared-react/hooks//bookmarks"; import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists"; import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context"; +import { useBookmarkListContext } from "@hoarder/shared-react/hooks/bookmark-list-context"; import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; import { useTagModel } from "./TagModal"; @@ -53,9 +54,12 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { setOpen: setManageListsModalOpen, content: manageListsModal } = useManageListsModal(bookmark.id); + const [deleteBookmarkDialogOpen, setDeleteBookmarkDialogOpen] = + useState(false); const [isTextEditorOpen, setTextEditorOpen] = useState(false); const { listId } = useBookmarkGridContext() ?? {}; + const withinListContext = useBookmarkListContext(); const onError = () => { toast({ @@ -63,14 +67,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { title: t("common.something_went_wrong"), }); }; - const deleteBookmarkMutator = useDeleteBookmark({ - onSuccess: () => { - toast({ - description: t("toasts.bookmarks.deleted"), - }); - }, - onError, - }); const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { @@ -112,6 +108,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { <> {tagModal} {manageListsModal} + <DeleteBookmarkConfirmationDialog + bookmark={bookmark} + open={deleteBookmarkDialogOpen} + setOpen={setDeleteBookmarkDialogOpen} + /> <BookmarkedTextEditor bookmark={bookmark} open={isTextEditorOpen} @@ -211,20 +212,22 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { <span>{t("actions.manage_lists")}</span> </DropdownMenuItem> - {listId && ( - <DropdownMenuItem - disabled={demoMode} - onClick={() => - removeFromListMutator.mutate({ - listId, - bookmarkId: bookmark.id, - }) - } - > - <ListX className="mr-2 size-4" /> - <span>{t("actions.remove_from_list")}</span> - </DropdownMenuItem> - )} + {listId && + withinListContext && + withinListContext.type === "manual" && ( + <DropdownMenuItem + disabled={demoMode} + onClick={() => + removeFromListMutator.mutate({ + listId, + bookmarkId: bookmark.id, + }) + } + > + <ListX className="mr-2 size-4" /> + <span>{t("actions.remove_from_list")}</span> + </DropdownMenuItem> + )} {bookmark.content.type === BookmarkTypes.LINK && ( <DropdownMenuItem @@ -240,9 +243,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { <DropdownMenuItem disabled={demoMode} className="text-destructive" - onClick={() => - deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } + onClick={() => setDeleteBookmarkDialogOpen(true)} > <Trash2 className="mr-2 size-4" /> <span>{t("actions.delete")}</span> diff --git a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx new file mode 100644 index 00000000..4a69e3d0 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx @@ -0,0 +1,63 @@ +import { usePathname, useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; + +import { useDeleteBookmark } from "@hoarder/shared-react/hooks//bookmarks"; +import { ZBookmark } from "@hoarder/shared/types/bookmarks"; + +export default function DeleteBookmarkConfirmationDialog({ + bookmark, + children, + open, + setOpen, +}: { + bookmark: ZBookmark; + children?: React.ReactNode; + open: boolean; + setOpen: (v: boolean) => void; +}) { + const { t } = useTranslation(); + const currentPath = usePathname(); + const router = useRouter(); + + const { mutate: deleteBoomark, isPending } = useDeleteBookmark({ + onSuccess: () => { + toast({ + description: t("toasts.bookmarks.deleted"), + }); + setOpen(false); + if (currentPath.includes(bookmark.id)) { + router.push("/dashboard/bookmarks"); + } + }, + onError: () => { + toast({ + variant: "destructive", + description: `Something went wrong`, + }); + }, + }); + + return ( + <ActionConfirmingDialog + open={open} + setOpen={setOpen} + title={t("dialogs.bookmarks.delete_confirmation_title")} + description={t("dialogs.bookmarks.delete_confirmation_description")} + actionButton={() => ( + <ActionButton + type="button" + variant="destructive" + loading={isPending} + onClick={() => deleteBoomark({ bookmarkId: bookmark.id })} + > + Delete + </ActionButton> + )} + > + {children} + </ActionConfirmingDialog> + ); +} diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index d66d7096..98bb19be 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -25,6 +25,13 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import data from "@emoji-mart/data"; @@ -38,7 +45,10 @@ import { useCreateBookmarkList, useEditBookmarkList, } from "@hoarder/shared-react/hooks/lists"; -import { ZBookmarkList } from "@hoarder/shared/types/lists"; +import { + ZBookmarkList, + zNewBookmarkListSchema, +} from "@hoarder/shared/types/lists"; import { BookmarkListSelector } from "./BookmarkListSelector"; @@ -46,13 +56,13 @@ export function EditListModal({ open: userOpen, setOpen: userSetOpen, list, - parent, + prefill, children, }: { open?: boolean; setOpen?: (v: boolean) => void; list?: ZBookmarkList; - parent?: ZBookmarkList; + prefill?: Partial<Omit<ZBookmarkList, "id">>; children?: React.ReactNode; }) { const { t } = useTranslation(); @@ -64,17 +74,14 @@ export function EditListModal({ throw new Error("You must provide both open and setOpen or neither"); } const [customOpen, customSetOpen] = useState(false); - const formSchema = z.object({ - name: z.string(), - icon: z.string(), - parentId: z.string().nullish(), - }); - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), + const form = useForm<z.infer<typeof zNewBookmarkListSchema>>({ + resolver: zodResolver(zNewBookmarkListSchema), defaultValues: { - name: list?.name ?? "", - icon: list?.icon ?? "🚀", - parentId: list?.parentId ?? parent?.id, + name: list?.name ?? prefill?.name ?? "", + icon: list?.icon ?? prefill?.icon ?? "🚀", + parentId: list?.parentId ?? prefill?.parentId, + type: list?.type ?? prefill?.type ?? "manual", + query: list?.query ?? prefill?.query ?? undefined, }, }); const [open, setOpen] = [ @@ -84,9 +91,11 @@ export function EditListModal({ useEffect(() => { form.reset({ - name: list?.name ?? "", - icon: list?.icon ?? "🚀", - parentId: list?.parentId ?? parent?.id, + name: list?.name ?? prefill?.name ?? "", + icon: list?.icon ?? prefill?.icon ?? "🚀", + parentId: list?.parentId ?? prefill?.parentId, + type: list?.type ?? prefill?.type ?? "manual", + query: list?.query ?? prefill?.query ?? undefined, }); }, [open]); @@ -154,14 +163,24 @@ export function EditListModal({ } }, }); + const listType = form.watch("type"); + + useEffect(() => { + if (listType !== "smart") { + form.resetField("query"); + } + }, [listType]); const isEdit = !!list; const isPending = isCreating || isEditing; - const onSubmit = form.handleSubmit((value: z.infer<typeof formSchema>) => { - value.parentId = value.parentId === "" ? null : value.parentId; - isEdit ? editList({ ...value, listId: list.id }) : createList(value); - }); + const onSubmit = form.handleSubmit( + (value: z.infer<typeof zNewBookmarkListSchema>) => { + value.parentId = value.parentId === "" ? null : value.parentId; + value.query = value.type === "smart" ? value.query : undefined; + isEdit ? editList({ ...value, listId: list.id }) : createList(value); + }, + ); return ( <Dialog @@ -176,7 +195,9 @@ export function EditListModal({ <Form {...form}> <form onSubmit={onSubmit}> <DialogHeader> - <DialogTitle>{isEdit ? "Edit" : "New"} List</DialogTitle> + <DialogTitle> + {isEdit ? t("lists.edit_list") : t("lists.new_list")} + </DialogTitle> </DialogHeader> <div className="flex w-full gap-2 py-4"> <FormField @@ -232,7 +253,7 @@ export function EditListModal({ render={({ field }) => { return ( <FormItem className="grow pb-4"> - <FormLabel>Parent</FormLabel> + <FormLabel>{t("lists.parent_list")}</FormLabel> <div className="flex items-center gap-1"> <FormControl> <BookmarkListSelector @@ -240,7 +261,7 @@ export function EditListModal({ hideSubtreeOf={list ? list.id : undefined} value={field.value} onChange={field.onChange} - placeholder={"No Parent"} + placeholder={t("lists.no_parent")} /> </FormControl> <Button @@ -258,6 +279,58 @@ export function EditListModal({ ); }} /> + <FormField + control={form.control} + name="type" + render={({ field }) => { + return ( + <FormItem className="grow pb-4"> + <FormLabel>{t("lists.list_type")}</FormLabel> + <FormControl> + <Select + disabled={isEdit} + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="manual"> + {t("lists.manual_list")} + </SelectItem> + <SelectItem value="smart"> + {t("lists.smart_list")} + </SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + {listType === "smart" && ( + <FormField + control={form.control} + name="query" + render={({ field }) => { + return ( + <FormItem className="grow pb-4"> + <FormLabel>{t("lists.search_query")}</FormLabel> + <FormControl> + <Input + value={field.value} + onChange={field.onChange} + placeholder={t("lists.search_query")} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + )} <DialogFooter className="sm:justify-end"> <DialogClose asChild> <Button type="button" variant="secondary"> diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index a6780e1e..b8bfb4ad 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -1,19 +1,24 @@ "use client"; +import { useMemo } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { MoreHorizontal } from "lucide-react"; +import { useTranslation } from "@/lib/i18n/client"; +import { MoreHorizontal, SearchIcon } from "lucide-react"; import { api } from "@hoarder/shared-react/trpc"; +import { parseSearchQuery } from "@hoarder/shared/searchQueryParser"; import { ZBookmarkList } from "@hoarder/shared/types/lists"; +import QueryExplainerTooltip from "../search/QueryExplainerTooltip"; import { ListOptions } from "./ListOptions"; export default function ListHeader({ initialData, }: { - initialData: ZBookmarkList & { bookmarks: string[] }; + initialData: ZBookmarkList; }) { + const { t } = useTranslation(); const router = useRouter(); const { data: list, error } = api.lists.get.useQuery( { @@ -24,6 +29,13 @@ export default function ListHeader({ }, ); + const parsedQuery = useMemo(() => { + if (!list.query) { + return null; + } + return parseSearchQuery(list.query); + }, [list.query]); + if (error) { // This is usually exercised during list deletions. if (error.data?.code == "NOT_FOUND") { @@ -33,10 +45,24 @@ export default function ListHeader({ return ( <div className="flex items-center justify-between"> - <span className="text-2xl"> - {list.icon} {list.name} - </span> - <div className="flex"> + <div className="flex items-center gap-2"> + <span className="text-2xl"> + {list.icon} {list.name} + </span> + </div> + <div className="flex items-center"> + {parsedQuery && ( + <QueryExplainerTooltip + header={ + <div className="flex items-center justify-center gap-1"> + <SearchIcon className="size-3" /> + <span className="text-sm">{t("lists.smart_list")}</span> + </div> + } + parsedSearchQuery={parsedQuery} + className="size-6 stroke-foreground" + /> + )} <ListOptions list={list}> <Button variant="ghost"> <MoreHorizontal /> diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx index e663a2e0..a7217954 100644 --- a/apps/web/components/dashboard/lists/ListOptions.tsx +++ b/apps/web/components/dashboard/lists/ListOptions.tsx @@ -33,7 +33,9 @@ export function ListOptions({ <EditListModal open={newNestedListModalOpen} setOpen={setNewNestedListModalOpen} - parent={list} + prefill={{ + parentId: list.id, + }} /> <EditListModal open={editModalOpen} diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx index 38ad8fa2..86c86d5a 100644 --- a/apps/web/components/dashboard/preview/ActionBar.tsx +++ b/apps/web/components/dashboard/preview/ActionBar.tsx @@ -1,5 +1,6 @@ -import { useRouter } from "next/navigation"; +import { useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, @@ -10,16 +11,16 @@ import { useTranslation } from "@/lib/i18n/client"; import { Trash2 } from "lucide-react"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; -import { - useDeleteBookmark, - useUpdateBookmark, -} from "@hoarder/shared-react/hooks/bookmarks"; +import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks"; +import DeleteBookmarkConfirmationDialog from "../bookmarks/DeleteBookmarkConfirmationDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "../bookmarks/icons"; export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); - const router = useRouter(); + const [deleteBookmarkDialogOpen, setDeleteBookmarkDialogOpen] = + useState(false); + const onError = () => { toast({ variant: "destructive", @@ -44,16 +45,6 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { }, onError, }); - const { mutate: deleteBookmark, isPending: pendingDeletion } = - useDeleteBookmark({ - onSuccess: () => { - toast({ - description: "The bookmark has been deleted!", - }); - router.back(); - }, - onError, - }); return ( <div className="flex items-center justify-center gap-3"> @@ -100,17 +91,19 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { </TooltipContent> </Tooltip> <Tooltip delayDuration={0}> + <DeleteBookmarkConfirmationDialog + bookmark={bookmark} + open={deleteBookmarkDialogOpen} + setOpen={setDeleteBookmarkDialogOpen} + /> <TooltipTrigger asChild> - <ActionButton - loading={pendingDeletion} + <Button className="size-14 rounded-full bg-background" variant="none" - onClick={() => { - deleteBookmark({ bookmarkId: bookmark.id }); - }} + onClick={() => setDeleteBookmarkDialogOpen(true)} > <Trash2 /> - </ActionButton> + </Button> </TooltipTrigger> <TooltipContent side="bottom">{t("actions.delete")}</TooltipContent> </Tooltip> diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index c257d902..fd70fd4e 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -114,7 +114,7 @@ export default function BookmarkPreview({ <div className="row-span-2 h-full w-full overflow-auto p-2 md:col-span-2 lg:row-auto"> {isBookmarkStillCrawling(bookmark) ? <ContentLoading /> : content} </div> - <div className="lg:col-span1 row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 lg:row-auto"> + <div className="row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 md:col-span-2 lg:col-span-1 lg:row-auto"> <div className="flex w-full flex-col items-center justify-center gap-y-2"> <EditableTitle bookmark={bookmark} /> {sourceUrl && ( diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx new file mode 100644 index 00000000..f5f73be3 --- /dev/null +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -0,0 +1,162 @@ +import InfoTooltip from "@/components/ui/info-tooltip"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { useTranslation } from "@/lib/i18n/client"; + +import { TextAndMatcher } from "@hoarder/shared/searchQueryParser"; +import { Matcher } from "@hoarder/shared/types/search"; + +export default function QueryExplainerTooltip({ + parsedSearchQuery, + header, + className, +}: { + header?: React.ReactNode; + parsedSearchQuery: TextAndMatcher & { result: string }; + className?: string; +}) { + const { t } = useTranslation(); + if (parsedSearchQuery.result == "invalid") { + return null; + } + + const MatcherComp = ({ matcher }: { matcher: Matcher }) => { + switch (matcher.type) { + case "tagName": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.does_not_have_tag") + : t("search.has_tag")} + </TableCell> + <TableCell>{matcher.tagName}</TableCell> + </TableRow> + ); + case "listName": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.is_not_in_list") + : t("search.is_in_list")} + </TableCell> + <TableCell>{matcher.listName}</TableCell> + </TableRow> + ); + case "dateAfter": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.not_created_on_or_after") + : t("search.created_on_or_after")} + </TableCell> + <TableCell>{matcher.dateAfter.toDateString()}</TableCell> + </TableRow> + ); + case "dateBefore": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.not_created_on_or_before") + : t("search.created_on_or_before")} + </TableCell> + <TableCell>{matcher.dateBefore.toDateString()}</TableCell> + </TableRow> + ); + case "favourited": + return ( + <TableRow> + <TableCell colSpan={2} className="text-center"> + {matcher.favourited + ? t("search.is_favorited") + : t("search.is_not_favorited")} + </TableCell> + </TableRow> + ); + case "archived": + return ( + <TableRow> + <TableCell colSpan={2} className="text-center"> + {matcher.archived + ? t("search.is_archived") + : t("search.is_not_archived")} + </TableCell> + </TableRow> + ); + case "tagged": + return ( + <TableRow> + <TableCell colSpan={2} className="text-center"> + {matcher.tagged + ? t("search.has_any_tag") + : t("search.has_no_tags")} + </TableCell> + </TableRow> + ); + case "inlist": + return ( + <TableRow> + <TableCell colSpan={2} className="text-center"> + {matcher.inList + ? t("search.is_in_any_list") + : t("search.is_not_in_any_list")} + </TableCell> + </TableRow> + ); + case "and": + case "or": + return ( + <TableRow> + <TableCell> + {matcher.type === "and" ? t("search.and") : t("search.or")} + </TableCell> + <TableCell> + <Table> + <TableBody> + {matcher.matchers.map((m, i) => ( + <MatcherComp key={i} matcher={m} /> + ))} + </TableBody> + </Table> + </TableCell> + </TableRow> + ); + case "url": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.url_does_not_contain") + : t("search.url_contains")} + </TableCell> + <TableCell>{matcher.url}</TableCell> + </TableRow> + ); + default: { + const _exhaustiveCheck: never = matcher; + return null; + } + } + }; + + return ( + <InfoTooltip className={className}> + {header} + <Table> + <TableBody> + {parsedSearchQuery.text && ( + <TableRow> + <TableCell>{t("search.full_text_search")}</TableCell> + <TableCell>{parsedSearchQuery.text}</TableCell> + </TableRow> + )} + {parsedSearchQuery.matcher && ( + <MatcherComp matcher={parsedSearchQuery.matcher} /> + )} + </TableBody> + </Table> + </InfoTooltip> + ); +} diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index 55f304e3..5e46fc18 100644 --- a/apps/web/components/dashboard/search/SearchInput.tsx +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -1,9 +1,14 @@ "use client"; -import React, { useEffect, useImperativeHandle, useRef } from "react"; +import React, { useEffect, useImperativeHandle, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search"; import { useTranslation } from "@/lib/i18n/client"; +import { cn } from "@/lib/utils"; + +import { EditListModal } from "../lists/EditListModal"; +import QueryExplainerTooltip from "./QueryExplainerTooltip"; function useFocusSearchOnKeyPress( inputRef: React.RefObject<HTMLInputElement>, @@ -47,7 +52,8 @@ const SearchInput = React.forwardRef< React.HTMLAttributes<HTMLInputElement> & { loading?: boolean } >(({ className, ...props }, ref) => { const { t } = useTranslation(); - const { debounceSearch, searchQuery, isInSearchPage } = useDoBookmarkSearch(); + const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } = + useDoBookmarkSearch(); const [value, setValue] = React.useState(searchQuery); @@ -59,6 +65,7 @@ const SearchInput = React.forwardRef< useFocusSearchOnKeyPress(inputRef, onChange); useImperativeHandle(ref, () => inputRef.current!); + const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); useEffect(() => { if (!isInSearchPage) { @@ -67,14 +74,38 @@ const SearchInput = React.forwardRef< }, [isInSearchPage]); return ( - <Input - ref={inputRef} - value={value} - onChange={onChange} - placeholder={t("common.search")} - className={className} - {...props} - /> + <div className={cn("relative flex-1", className)}> + <EditListModal + open={newNestedListModalOpen} + setOpen={setNewNestedListModalOpen} + prefill={{ + type: "smart", + query: value, + }} + /> + <QueryExplainerTooltip + className="-translate-1/2 absolute right-1.5 top-2 stroke-foreground p-0.5" + parsedSearchQuery={parsedSearchQuery} + /> + {parsedSearchQuery.result === "full" && + parsedSearchQuery.text.length == 0 && ( + <Button + onClick={() => setNewNestedListModalOpen(true)} + size="none" + variant="secondary" + className="absolute right-9 top-2 z-50 px-2 py-1 text-xs" + > + {t("actions.save")} + </Button> + )} + <Input + ref={inputRef} + value={value} + onChange={onChange} + placeholder={t("common.search")} + {...props} + /> + </div> ); }); SearchInput.displayName = "SearchInput"; diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts index 9890ac6f..386355f7 100644 --- a/apps/web/lib/hooks/bookmark-search.ts +++ b/apps/web/lib/hooks/bookmark-search.ts @@ -1,17 +1,20 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { api } from "@/lib/trpc"; import { keepPreviousData } from "@tanstack/react-query"; +import { parseSearchQuery } from "@hoarder/shared/searchQueryParser"; + function useSearchQuery() { const searchParams = useSearchParams(); - const searchQuery = searchParams.get("q") ?? ""; - return { searchQuery }; + const searchQuery = decodeURIComponent(searchParams.get("q") ?? ""); + const parsed = useMemo(() => parseSearchQuery(searchQuery), [searchQuery]); + return { searchQuery, parsedSearchQuery: parsed }; } export function useDoBookmarkSearch() { const router = useRouter(); - const { searchQuery } = useSearchQuery(); + const { searchQuery, parsedSearchQuery } = useSearchQuery(); const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>(); const pathname = usePathname(); @@ -26,7 +29,7 @@ export function useDoBookmarkSearch() { const doSearch = (val: string) => { setTimeoutId(undefined); - router.replace(`/dashboard/search?q=${val}`); + router.replace(`/dashboard/search?q=${encodeURIComponent(val)}`); }; const debounceSearch = (val: string) => { @@ -43,6 +46,7 @@ export function useDoBookmarkSearch() { doSearch, debounceSearch, searchQuery, + parsedSearchQuery, isInSearchPage: pathname.startsWith("/dashboard/search"), }; } @@ -75,7 +79,6 @@ export function useBookmarkSearch() { } return { - searchQuery, error, data, isPending, diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 92f6e956..e1bd443a 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -166,7 +166,14 @@ "all_lists": "All Lists", "favourites": "Favourites", "new_list": "New List", - "new_nested_list": "New Nested List" + "edit_list": "Edit List", + "new_nested_list": "New Nested List", + "parent_list": "Parent List", + "no_parent": "No Parent", + "list_type": "List Type", + "manual_list": "Manual List", + "smart_list": "Smart List", + "search_query": "Search Query" }, "tags": { "all_tags": "All Tags", @@ -181,6 +188,29 @@ "drag_and_drop_merging_info": "Drag and drop tags on each other to merge them", "sort_by_name": "Sort by Name" }, + "search": { + "is_favorited": "Is Favorited", + "is_not_favorited": "Is Not Favorited", + "is_archived": "Is Archived", + "is_not_archived": "Is Not Archived", + "has_any_tag": "Has Any Tag", + "has_no_tags": "Has No Tag", + "is_in_any_list": "Is In Any List", + "is_not_in_any_list": "Is not In Any List", + "created_on_or_after": "Created on or After", + "not_created_on_or_after": "Not Created on or After", + "created_on_or_before": "Created on or Before", + "not_created_on_or_before": "Not Created on or Before", + "url_contains": "URL Contains", + "url_does_not_contain": "URL Does Not Contain", + "is_in_list": "Is In List", + "is_not_in_list": "Is not In List", + "has_tag": "Has Tag", + "does_not_have_tag": "Does Not Have Tag", + "full_text_search": "Full Text Search", + "and": "And", + "or": "Or" + }, "preview": { "view_original": "View Original", "cached_content": "Cached Content" @@ -243,6 +273,12 @@ } } }, + "dialogs": { + "bookmarks": { + "delete_confirmation_title": "Delete Bookmark?", + "delete_confirmation_description": "Are you sure you want to delete this bookmark?" + } + }, "toasts": { "bookmarks": { "updated": "The bookmark has been updated!", diff --git a/apps/web/package.json b/apps/web/package.json index 96058ece..d2cbd589 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,9 @@ "lint": "next lint", "test": "vitest", "typecheck": "tsc --noEmit", - "format": "prettier --check . --ignore-path ../../.gitignore" + "format": "prettier --check . --ignore-path ../../.gitignore", + "format:fix": "prettier --write . --ignore-path ../../.gitignore", + "lint:fix": "next lint --fix" }, "dependencies": { "@auth/drizzle-adapter": "^1.4.2", @@ -61,7 +63,7 @@ "i18next-resources-to-backend": "^1.2.1", "lexical": "^0.20.2", "lucide-react": "^0.330.0", - "next": "14.2.15", + "next": "14.2.21", "next-auth": "^4.24.5", "next-i18next": "^15.3.1", "next-pwa": "^5.6.0", diff --git a/apps/workers/package.json b/apps/workers/package.json index 61d429a6..1e522024 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -53,7 +53,9 @@ "start": "tsx watch index.ts", "start:prod": "tsx index.ts", "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --ignore-path ../../.prettierignore --write", "typecheck": "tsc --noEmit" }, "eslintConfig": { |
