diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00cc834..5cdad9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+# [1.27.0](https://github.com/skuong/mexboard/compare/v1.26.1...v1.27.0) (2026-06-30)
+
+
+### Bug Fixes
+
+* handle image preview for Windows ([95ce989](https://github.com/skuong/mexboard/commit/95ce989f79f4b1019688fb17e825e2703dea834f))
+
+
+### Features
+
+* add infinite scroll ([31ce43e](https://github.com/skuong/mexboard/commit/31ce43e6f97477a9461f0b49f4969028e74a412b))
+
## [1.26.1](https://github.com/skuong/mexboard/compare/v1.26.0...v1.26.1) (2026-06-30)
diff --git a/package.json b/package.json
index e02c492..d4e1ae2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mexboard",
"private": false,
- "version": "1.26.1",
+ "version": "1.27.0",
"type": "module",
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485",
"scripts": {
@@ -53,6 +53,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.75.0",
+ "react-intersection-observer": "^10.0.3",
"shadcn": "^4.1.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fab4719..a607bcc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -122,6 +122,9 @@ importers:
react-hook-form:
specifier: ^7.75.0
version: 7.75.0(react@19.2.3)
+ react-intersection-observer:
+ specifier: ^10.0.3
+ version: 10.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
shadcn:
specifier: ^4.1.1
version: 4.1.1(@types/node@25.9.1)(typescript@5.8.3)
@@ -4775,6 +4778,15 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
+ react-intersection-observer@10.0.3:
+ resolution: {integrity: sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -10058,6 +10070,12 @@ snapshots:
dependencies:
react: 19.2.3
+ react-intersection-observer@10.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ react-dom: 19.2.3(react@19.2.3)
+
react-is@16.13.1: {}
react-is@19.2.5: {}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 6a4ee1e..44131ed 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3509,7 +3509,7 @@ dependencies = [
[[package]]
name = "mexboard"
-version = "1.26.1"
+version = "1.27.0"
dependencies = [
"apple-native-keyring-store",
"arboard",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 3567f5f..f5ddda0 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mexboard"
-version = "1.26.1"
+version = "1.27.0"
description = "Clipboard manager with symbols/emojis selection and customizable features"
authors = ["Sokhuong"]
edition = "2021"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index f3e60e5..5673000 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Mexboard",
- "version": "1.26.1",
+ "version": "1.27.0",
"identifier": "com.mexboard",
"build": {
"beforeDevCommand": "pnpm dev",
diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..10de661
--- /dev/null
+++ b/src/components/ui/context-menu.tsx
@@ -0,0 +1,251 @@
+import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu';
+import { ChevronRightIcon } from 'lucide-react';
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
+ return ;
+}
+
+function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
+ return ;
+}
+
+function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
+ return (
+
+ );
+}
+
+function ContextMenuContent({
+ className,
+ align = 'start',
+ alignOffset = 4,
+ side = 'right',
+ sideOffset = 0,
+ ...props
+}: ContextMenuPrimitive.Popup.Props &
+ Pick) {
+ return (
+
+
+
+
+
+ );
+}
+
+function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
+ return ;
+}
+
+function ContextMenuLabel({
+ className,
+ inset,
+ ...props
+}: ContextMenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function ContextMenuItem({
+ className,
+ inset,
+ variant = 'default',
+ ...props
+}: ContextMenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: 'default' | 'destructive';
+}) {
+ return (
+
+ );
+}
+
+function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
+ return ;
+}
+
+function ContextMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: ContextMenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function ContextMenuSubContent({ ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function ContextMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: ContextMenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) {
+ return ;
+}
+
+function ContextMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: ContextMenuPrimitive.RadioItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
+ return (
+
+ );
+}
+
+function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+export {
+ ContextMenu,
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuGroup,
+ ContextMenuItem,
+ ContextMenuLabel,
+ ContextMenuPortal,
+ ContextMenuRadioGroup,
+ ContextMenuRadioItem,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuTrigger,
+};
diff --git a/src/features/clipboard/components/clipboard-item-card.tsx b/src/features/clipboard/components/clipboard-item-card.tsx
index 85f3c2a..827d8f4 100644
--- a/src/features/clipboard/components/clipboard-item-card.tsx
+++ b/src/features/clipboard/components/clipboard-item-card.tsx
@@ -1,10 +1,21 @@
+import { useQueryClient } from '@tanstack/react-query';
import { memo, useRef } from 'react';
import { commands } from '@/bindings';
import { Card, CardContent } from '@/components/ui/card';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuGroup,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from '@/components/ui/context-menu';
import { ClipboardItemContent } from '@/features/clipboard/components/clipboard-item-content';
+import { QUERY_KEY } from '@/features/clipboard/constant/query-key';
import { useClipboardItem } from '@/features/clipboard/hooks/use-clipboard-item';
export const ClipboardItemCard = memo(function ClipboardItem() {
+ const queryClient = useQueryClient();
+
const cardRef = useRef(null);
const item = useClipboardItem();
@@ -19,9 +30,41 @@ export const ClipboardItemCard = memo(function ClipboardItem() {
commands.pasteClipboardToOtherApp(item.id);
}}
>
-
-
-
+
+
+
+
+
+
+
+
+
+ {
+ const { status } = await commands.writeClipboardItemToSystemClipboard(item.id);
+ if (status === 'ok') {
+ queryClient.invalidateQueries({ queryKey: [QUERY_KEY.CLIPBOARDS] });
+ }
+ }}
+ className="cursor-pointer"
+ >
+ Copy
+
+
+ {
+ const { status } = await commands.deleteClipboardItem(item.id);
+ if (status === 'ok') {
+ queryClient.invalidateQueries({ queryKey: [QUERY_KEY.CLIPBOARDS] });
+ }
+ }}
+ className="cursor-pointer"
+ >
+ Delete
+
+
+
+
);
});
diff --git a/src/features/clipboard/components/clipboard-item-content.tsx b/src/features/clipboard/components/clipboard-item-content.tsx
index 7d16bb5..d720eb1 100644
--- a/src/features/clipboard/components/clipboard-item-content.tsx
+++ b/src/features/clipboard/components/clipboard-item-content.tsx
@@ -1,7 +1,12 @@
+import { platform } from '@tauri-apps/plugin-os';
import { useClipboardItem } from '@/features/clipboard/hooks/use-clipboard-item';
export function ClipboardItemContent() {
const item = useClipboardItem();
+ const previewSrc =
+ platform() === 'windows'
+ ? `http://image.localhost/preview?id=${item.id}`
+ : `image://localhost/preview?id=${item.id}`;
return (
<>
@@ -15,11 +20,7 @@ export function ClipboardItemContent() {
{!item.content && (
-

+
)}
>
diff --git a/src/components/clipboard-list.tsx b/src/features/clipboard/components/clipboard-list.tsx
similarity index 74%
rename from src/components/clipboard-list.tsx
rename to src/features/clipboard/components/clipboard-list.tsx
index 479d4ee..dac0a06 100644
--- a/src/components/clipboard-list.tsx
+++ b/src/features/clipboard/components/clipboard-list.tsx
@@ -1,15 +1,15 @@
import { load } from '@tauri-apps/plugin-store';
import { useEffect } from 'react';
import { EmptyState } from '@/components/clipboard-empty-state';
-import { Button } from '@/components/ui/button';
+import { InfiniteScrollSentinelListItem } from '@/features/clipboard/components/infinite-scroll-sentinel-list-item';
+import { SortableClipboardItem } from '@/features/clipboard/components/sortable-clipboard-item';
import { useClipboards } from '@/features/clipboard/hooks/use-clipboards';
import { useClipboardSearchQueryStore } from '@/features/clipboard/stores/clipboard-search-query-store';
import { useClipboardPerPageLimitStore } from '@/features/clipboard/stores/use-clipboard-per-page-limit-store';
-import { SortableItem } from './sortable-item';
export const ClipboardList = () => {
const { perPageLimit, setPerPageLimit } = useClipboardPerPageLimitStore();
- const { data, hasNextPage, fetchNextPage } = useClipboards(perPageLimit);
+ const { data, hasNextPage } = useClipboards(perPageLimit);
useEffect(() => {
const getPerPageLimitFromTauriStore = async () => {
@@ -36,20 +36,10 @@ export const ClipboardList = () => {
return (
{clipboards.map((clipboard) => (
-
+
))}
- {hasNextPage && (
- -
-
-
- )}
+ {hasNextPage && }
);
};
diff --git a/src/features/clipboard/components/clipboard-tab.tsx b/src/features/clipboard/components/clipboard-tab.tsx
index ec183d4..0e7eb8f 100644
--- a/src/features/clipboard/components/clipboard-tab.tsx
+++ b/src/features/clipboard/components/clipboard-tab.tsx
@@ -1,5 +1,5 @@
import { ClipboardItemSkeletonList } from '@/components/clipboard-item-skeleton';
-import { ClipboardList } from '@/components/clipboard-list';
+import { ClipboardList } from '@/features/clipboard/components/clipboard-list';
import { ClipboardTabHeader } from '@/features/clipboard/components/clipboard-tab-header';
import { useClipboardHistory } from '@/features/clipboard/hooks/use-clipboard-history';
import { useClipboardPerPageLimitStore } from '@/features/clipboard/stores/use-clipboard-per-page-limit-store';
diff --git a/src/features/clipboard/components/infinite-scroll-sentinel-list-item.tsx b/src/features/clipboard/components/infinite-scroll-sentinel-list-item.tsx
new file mode 100644
index 0000000..6d909bd
--- /dev/null
+++ b/src/features/clipboard/components/infinite-scroll-sentinel-list-item.tsx
@@ -0,0 +1,22 @@
+import { useEffect } from 'react';
+import { useInView } from 'react-intersection-observer';
+import { useClipboards } from '@/features/clipboard/hooks/use-clipboards';
+import { useClipboardPerPageLimitStore } from '@/features/clipboard/stores/use-clipboard-per-page-limit-store';
+
+export function InfiniteScrollSentinelListItem() {
+ const perPageLimit = useClipboardPerPageLimitStore((state) => state.perPageLimit);
+ const { hasNextPage, fetchNextPage, isFetchingNextPage } = useClipboards(perPageLimit);
+ const { ref, inView } = useInView();
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ return (
+
+ Loading...
+
+ );
+}
diff --git a/src/components/sortable-item.tsx b/src/features/clipboard/components/sortable-clipboard-item.tsx
similarity index 80%
rename from src/components/sortable-item.tsx
rename to src/features/clipboard/components/sortable-clipboard-item.tsx
index 5b122e9..6092563 100644
--- a/src/components/sortable-item.tsx
+++ b/src/features/clipboard/components/sortable-clipboard-item.tsx
@@ -3,7 +3,7 @@ import type { Clipboard } from '@/bindings';
import { ClipboardItemCard } from '@/features/clipboard/components/clipboard-item-card';
import { ClipboardItemContextProvider } from '@/features/clipboard/components/clipboard-item-context-provider';
-export const SortableItem = memo(function SortableItem({ item }: { item: Clipboard }) {
+export const SortableClipboardItem = memo(function SortableItem({ item }: { item: Clipboard }) {
return (