diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-02 20:13:57 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-11-02 20:13:57 +0000 |
| commit | 34d2b48578532d387b1466c82ae4a761cd1d1a4f (patch) | |
| tree | 0381690e050348a0bddd7ce2650203f1cdc6c694 /apps/web | |
| parent | bb00c99678ce3ca0493599c730a357f9daf2346b (diff) | |
| download | karakeep-34d2b48578532d387b1466c82ae4a761cd1d1a4f.tar.zst | |
feat: Add view options to show tag/title and control image fit. Fixes #1960
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/components/dashboard/ViewOptions.tsx | 154 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx | 61 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 7 | ||||
| -rw-r--r-- | apps/web/lib/userLocalSettings/bookmarksLayout.tsx | 6 | ||||
| -rw-r--r-- | apps/web/lib/userLocalSettings/types.ts | 3 | ||||
| -rw-r--r-- | apps/web/lib/userLocalSettings/userLocalSettings.ts | 12 |
6 files changed, 214 insertions, 29 deletions
diff --git a/apps/web/components/dashboard/ViewOptions.tsx b/apps/web/components/dashboard/ViewOptions.tsx index c86f43fb..e7b459fe 100644 --- a/apps/web/components/dashboard/ViewOptions.tsx +++ b/apps/web/components/dashboard/ViewOptions.tsx @@ -1,6 +1,12 @@ "use client"; -import { createElement, useEffect, useState } from "react"; +import { + createElement, + useEffect, + useOptimistic, + useState, + useTransition, +} from "react"; import { ButtonWithTooltip } from "@/components/ui/button"; import { DropdownMenu, @@ -21,10 +27,15 @@ import { import { updateBookmarksLayout, updateGridColumns, + updateImageFit, updateShowNotes, + updateShowTags, + updateShowTitle, } from "@/lib/userLocalSettings/userLocalSettings"; import { Check, + Heading, + Image, LayoutDashboard, LayoutGrid, LayoutList, @@ -32,6 +43,7 @@ import { LucideIcon, NotepadText, Settings, + Tag, } from "lucide-react"; type LayoutType = "masonry" | "grid" | "list" | "compact"; @@ -47,16 +59,73 @@ export default function ViewOptions() { const { t } = useTranslation(); const layout = useBookmarkLayout(); const gridColumns = useGridColumns(); - const { showNotes } = useBookmarkDisplaySettings(); + const actualDisplaySettings = useBookmarkDisplaySettings(); const [tempColumns, setTempColumns] = useState(gridColumns); + const [, startTransition] = useTransition(); + + // Optimistic state for all toggles + const [optimisticDisplaySettings, setOptimisticDisplaySettings] = + useOptimistic(actualDisplaySettings); + + const [optimisticLayout, setOptimisticLayout] = useOptimistic(layout); - const showColumnSlider = layout === "grid" || layout === "masonry"; + const [optimisticImageFit, setOptimisticImageFit] = useOptimistic( + actualDisplaySettings.imageFit, + ); + + const showColumnSlider = + optimisticLayout === "grid" || optimisticLayout === "masonry"; // Update temp value when actual value changes useEffect(() => { setTempColumns(gridColumns); }, [gridColumns]); + // Handlers with optimistic updates + const handleLayoutChange = (newLayout: LayoutType) => { + startTransition(async () => { + setOptimisticLayout(newLayout); + await updateBookmarksLayout(newLayout); + }); + }; + + const handleShowNotesChange = (checked: boolean) => { + startTransition(async () => { + setOptimisticDisplaySettings({ + ...optimisticDisplaySettings, + showNotes: checked, + }); + await updateShowNotes(checked); + }); + }; + + const handleShowTagsChange = (checked: boolean) => { + startTransition(async () => { + setOptimisticDisplaySettings({ + ...optimisticDisplaySettings, + showTags: checked, + }); + await updateShowTags(checked); + }); + }; + + const handleShowTitleChange = (checked: boolean) => { + startTransition(async () => { + setOptimisticDisplaySettings({ + ...optimisticDisplaySettings, + showTitle: checked, + }); + await updateShowTitle(checked); + }); + }; + + const handleImageFitChange = (fit: "cover" | "contain") => { + startTransition(async () => { + setOptimisticImageFit(fit); + await updateImageFit(fit); + }); + }; + return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -76,13 +145,16 @@ export default function ViewOptions() { <DropdownMenuItem key={key} className="cursor-pointer justify-between" - onClick={async () => await updateBookmarksLayout(key as LayoutType)} + onSelect={(e) => { + e.preventDefault(); + handleLayoutChange(key); + }} > <div className="flex items-center gap-2"> {createElement(iconMap[key as LayoutType], { size: 18 })} <span>{t(`layouts.${key}`)}</span> </div> - {layout === key && <Check className="ml-2 size-4" />} + {optimisticLayout === key && <Check className="ml-2 size-4" />} </DropdownMenuItem> ))} @@ -131,11 +203,79 @@ export default function ViewOptions() { </Label> <Switch id="show-notes" - checked={showNotes} - onCheckedChange={updateShowNotes} + checked={optimisticDisplaySettings.showNotes} + onCheckedChange={handleShowNotesChange} + /> + </div> + + <div className="flex items-center justify-between"> + <Label + htmlFor="show-tags" + className="flex cursor-pointer items-center gap-2 text-sm" + > + <Tag size={16} /> + <span>{t("view_options.show_tags")}</span> + </Label> + <Switch + id="show-tags" + checked={optimisticDisplaySettings.showTags} + onCheckedChange={handleShowTagsChange} + /> + </div> + + <div className="flex items-center justify-between"> + <Label + htmlFor="show-title" + className="flex cursor-pointer items-center gap-2 text-sm" + > + <Heading size={16} /> + <span>{t("view_options.show_title")}</span> + </Label> + <Switch + id="show-title" + checked={optimisticDisplaySettings.showTitle} + onCheckedChange={handleShowTitleChange} /> </div> </div> + + <DropdownMenuSeparator /> + <div className="px-2 py-1.5 text-sm font-semibold"> + {t("view_options.image_options")} + </div> + + <div className="space-y-1 px-2 py-2"> + <DropdownMenuItem + className="cursor-pointer justify-between" + onSelect={(e) => { + e.preventDefault(); + handleImageFitChange("cover"); + }} + > + <div className="flex items-center gap-2"> + <Image size={16} /> + <span>{t("view_options.image_fit_cover")}</span> + </div> + {optimisticImageFit === "cover" && ( + <Check className="ml-2 size-4" /> + )} + </DropdownMenuItem> + <DropdownMenuItem + className="cursor-pointer justify-between" + onSelect={(e) => { + e.preventDefault(); + handleImageFitChange("contain"); + }} + > + <div className="flex items-center gap-2"> + <Image size={16} /> + <span>{t("view_options.image_fit_contain")}</span> + </div> + {optimisticImageFit === "contain" && ( + <Check className="ml-2 size-4" /> + )} + </DropdownMenuItem> + </div> </DropdownMenuContent> </DropdownMenu> ); diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index 26c1c72b..b53a14c5 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -18,6 +18,7 @@ import { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils"; +import { switchCase } from "@karakeep/shared/utils/switch"; import BookmarkActionBar from "./BookmarkActionBar"; import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt"; @@ -117,7 +118,12 @@ function ListView({ footer, className, }: Props) { - const { showNotes } = useBookmarkDisplaySettings(); + const { showNotes, showTags, showTitle, imageFit } = + useBookmarkDisplaySettings(); + const imgFitClass = switchCase(imageFit, { + cover: "object-cover", + contain: "object-contain", + }); const note = showNotes ? bookmark.note?.trim() : undefined; return ( @@ -129,23 +135,25 @@ function ListView({ > <MultiBookmarkSelector bookmark={bookmark} /> <div className="flex size-32 items-center justify-center overflow-hidden"> - {image("list", "object-cover rounded-lg size-32")} + {image("list", cn("size-32 rounded-lg", imgFitClass))} </div> <div className="flex h-full flex-1 flex-col justify-between gap-2 overflow-hidden"> <div className="flex flex-col gap-2 overflow-hidden"> - {title && ( + {showTitle && title && ( <div className="line-clamp-2 flex-none shrink-0 overflow-hidden text-ellipsis break-words text-lg"> {title} </div> )} {content && <div className="shrink-1 overflow-hidden">{content}</div>} {note && <NotePreview note={note} bookmarkId={bookmark.id} />} - <div className="flex shrink-0 flex-wrap gap-1 overflow-hidden"> - <TagList - bookmark={bookmark} - loading={isBookmarkStillTagging(bookmark)} - /> - </div> + {showTags && ( + <div className="flex shrink-0 flex-wrap gap-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + )} </div> <BottomRow footer={footer} bookmark={bookmark} /> </div> @@ -164,9 +172,17 @@ function GridView({ layout, fitHeight = false, }: Props & { layout: BookmarksLayoutTypes }) { - const { showNotes } = useBookmarkDisplaySettings(); + const { showNotes, showTags, showTitle, imageFit } = + useBookmarkDisplaySettings(); + const imgFitClass = switchCase(imageFit, { + cover: "object-cover", + contain: "object-contain", + }); const note = showNotes ? bookmark.note?.trim() : undefined; - const img = image("grid", "h-52 min-h-52 w-full object-cover rounded-t-lg"); + const img = image( + "grid", + cn("h-52 min-h-52 w-full rounded-t-lg", imgFitClass), + ); return ( <div @@ -180,20 +196,22 @@ function GridView({ {img && <div className="h-52 w-full shrink-0 overflow-hidden">{img}</div>} <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> <div className="grow-1 flex flex-col gap-2 overflow-hidden"> - {title && ( + {showTitle && title && ( <div className="line-clamp-2 flex-none shrink-0 overflow-hidden text-ellipsis break-words text-lg"> {title} </div> )} {content && <div className="shrink-1 overflow-hidden">{content}</div>} {note && <NotePreview note={note} bookmarkId={bookmark.id} />} - <div className="flex shrink-0 flex-wrap gap-1 overflow-hidden"> - <TagList - className={wrapTags ? undefined : "h-full"} - bookmark={bookmark} - loading={isBookmarkStillTagging(bookmark)} - /> - </div> + {showTags && ( + <div className="flex shrink-0 flex-wrap gap-1 overflow-hidden"> + <TagList + className={wrapTags ? undefined : "h-full"} + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + )} </div> <BottomRow footer={footer} bookmark={bookmark} /> </div> @@ -202,6 +220,7 @@ function GridView({ } function CompactView({ bookmark, title, footer, className }: Props) { + const { showTitle } = useBookmarkDisplaySettings(); return ( <div className={cn( @@ -230,11 +249,11 @@ function CompactView({ bookmark, title, footer, className }: Props) { {bookmark.content.type === BookmarkTypes.ASSET && ( <ImageIcon className="size-5" /> )} - { + {showTitle && ( <div className="shrink-1 text-md line-clamp-1 overflow-hidden text-ellipsis break-words"> {title ?? "Untitled"} </div> - } + )} {footer && ( <p className="flex shrink-0 gap-2 text-gray-500">•{footer}</p> )} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 9b40416d..5aa2913d 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -52,7 +52,12 @@ "layout": "Layout", "columns": "Columns", "display_options": "Display Options", - "show_note_previews": "Show Notes" + "show_note_previews": "Show Notes", + "show_tags": "Show Tags", + "show_title": "Show Title", + "image_options": "Image Options", + "image_fit_cover": "Cover (Fill)", + "image_fit_contain": "Contain (Fit)" }, "actions": { "change_layout": "Change Layout", diff --git a/apps/web/lib/userLocalSettings/bookmarksLayout.tsx b/apps/web/lib/userLocalSettings/bookmarksLayout.tsx index 504d8d8c..3bd77e4a 100644 --- a/apps/web/lib/userLocalSettings/bookmarksLayout.tsx +++ b/apps/web/lib/userLocalSettings/bookmarksLayout.tsx @@ -15,6 +15,9 @@ export const UserLocalSettingsCtx = createContext< lang: fallbackLng, gridColumns: 3, showNotes: false, + showTags: true, + showTitle: true, + imageFit: "cover", }); function useUserLocalSettings() { @@ -25,6 +28,9 @@ export function useBookmarkDisplaySettings() { const settings = useUserLocalSettings(); return { showNotes: settings.showNotes, + showTags: settings.showTags, + showTitle: settings.showTitle, + imageFit: settings.imageFit, }; } diff --git a/apps/web/lib/userLocalSettings/types.ts b/apps/web/lib/userLocalSettings/types.ts index 54b75b80..949c9229 100644 --- a/apps/web/lib/userLocalSettings/types.ts +++ b/apps/web/lib/userLocalSettings/types.ts @@ -10,6 +10,9 @@ export const zUserLocalSettings = z.object({ lang: z.string().optional().default("en"), gridColumns: z.number().min(1).max(6).optional().default(3), showNotes: z.boolean().optional().default(false), + showTags: z.boolean().optional().default(true), + showTitle: z.boolean().optional().default(true), + imageFit: z.enum(["cover", "contain"]).optional().default("cover"), }); export type UserLocalSettings = z.infer<typeof zUserLocalSettings>; diff --git a/apps/web/lib/userLocalSettings/userLocalSettings.ts b/apps/web/lib/userLocalSettings/userLocalSettings.ts index aff3645c..55070cd6 100644 --- a/apps/web/lib/userLocalSettings/userLocalSettings.ts +++ b/apps/web/lib/userLocalSettings/userLocalSettings.ts @@ -46,3 +46,15 @@ export async function updateGridColumns(gridColumns: number) { export async function updateShowNotes(showNotes: boolean) { await readModifyWrite(() => ({ showNotes })); } + +export async function updateShowTags(showTags: boolean) { + await readModifyWrite(() => ({ showTags })); +} + +export async function updateShowTitle(showTitle: boolean) { + await readModifyWrite(() => ({ showTitle })); +} + +export async function updateImageFit(imageFit: "cover" | "contain") { + await readModifyWrite(() => ({ imageFit })); +} |
