feat: pin frequently-used tools to the top of the dashboard (#440)

* feat(i18n): add pin/unpin/pinned strings, retire addToFavourites stub

Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp

* feat(web): add per-user pinned-tools store

Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp

* feat(web): add opt-in pin toggle to ToolCard

Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp

* feat(web): render Pinned section on the dashboard All tab

Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp

* test(web): cover pin toggle (component) and dashboard pin flow (e2e)

Claude-Session: https://claude.ai/code/session_01Ad5LjCDJyW1tLFd3P4Hedp
This commit is contained in:
SnapOtter
2026-07-06 17:59:30 +08:00
committed by GitHub
parent ec78d36d95
commit 3aaaacc7a1
27 changed files with 472 additions and 35 deletions
+46 -3
View File
@@ -1,6 +1,6 @@
import type { Tool } from "@snapotter/shared";
import { PYTHON_SIDECAR_TOOLS, SECTIONS, TOOL_BUNDLE_MAP, toolSection } from "@snapotter/shared";
import { Clock, Download, FileImage, Loader2 } from "lucide-react";
import { Clock, Download, FileImage, Loader2, Pin } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
@@ -8,18 +8,51 @@ import { ICON_MAP } from "@/lib/icon-map";
import { getToolDescription, getToolName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
import { useFeaturesStore } from "@/stores/features-store";
import { usePinnedToolsStore } from "@/stores/pinned-tools-store";
interface ToolCardProps {
tool: Tool;
variant?: "compact" | "descriptive";
showModalityBadge?: boolean;
showPin?: boolean;
}
function PinButton({ toolId }: { toolId: string }) {
const { t } = useTranslation();
const pinned = usePinnedToolsStore((s) => s.pinnedTools.includes(toolId));
const pin = usePinnedToolsStore((s) => s.pin);
const unpin = usePinnedToolsStore((s) => s.unpin);
const label = pinned ? t.toolCard.unpin : t.toolCard.pin;
return (
<button
type="button"
data-testid={`pin-toggle-${toolId}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (pinned) unpin(toolId);
else pin(toolId);
}}
aria-pressed={pinned}
aria-label={label}
title={label}
className={cn(
"absolute top-2 end-2 z-10 p-1.5 rounded-md transition-colors",
pinned
? "text-primary hover:bg-primary/10"
: "text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted",
)}
>
<Pin className={cn("h-4 w-4", pinned && "fill-current")} aria-hidden="true" />
</button>
);
}
const SECTION_COLOR_MAP: Record<string, string> = Object.fromEntries(
SECTIONS.map((s) => [s.id, s.color]),
);
export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolCardProps) {
export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin }: ToolCardProps) {
const { t } = useTranslation();
const IconComponent =
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
@@ -66,12 +99,13 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
) : null;
if (variant === "descriptive") {
return (
const card = (
<Link
to={tool.route}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border border-border/60 bg-card transition-all",
"hover:border-border hover:shadow-sm",
showPin && "pe-10",
tool.disabled && "opacity-50 pointer-events-none",
)}
>
@@ -100,6 +134,15 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
</div>
</Link>
);
if (!showPin) return card;
return (
<div className="relative">
{card}
<PinButton toolId={tool.id} />
</div>
);
}
return (
+43 -11
View File
@@ -17,6 +17,7 @@ import { getCategoryName, getToolName } from "@/lib/tool-i18n.js";
import { buildToolRequestDiscussionUrl } from "@/lib/tool-request.js";
import { cn } from "@/lib/utils.js";
import { useAnalyticsStore } from "@/stores/analytics-store";
import { usePinnedToolsStore } from "@/stores/pinned-tools-store";
import { useSettingsStore } from "@/stores/settings-store";
interface TabDef {
@@ -47,6 +48,8 @@ export function HomePage() {
const [search, setSearch] = useState("");
const { fetch: fetchSettings, disabledTools, experimentalEnabled, loaded } = useSettingsStore();
const recentToolIds = useRecentTools();
const pinnedIds = usePinnedToolsStore((s) => s.pinnedTools);
const fetchPins = usePinnedToolsStore((s) => s.fetch);
const analyticsConfig = useAnalyticsStore((s) => s.config);
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
const analyticsOn = analyticsConfigLoaded && analyticsConfig?.enabled === true;
@@ -67,6 +70,10 @@ export function HomePage() {
fetchSettings();
}, [fetchSettings]);
useEffect(() => {
fetchPins();
}, [fetchPins]);
// Open a specific section tab when arriving via a breadcrumb link
// (/?section=<sectionId>), then clean the URL so refresh/back doesn't re-pin it.
useEffect(() => {
@@ -137,13 +144,18 @@ export function HomePage() {
return counts;
}, [visibleTools]);
const recentTools = useMemo(
() =>
recentToolIds
.map((id) => visibleTools.find((tool) => tool.id === id))
.filter((tool): tool is Tool => tool != null),
[recentToolIds, visibleTools],
);
const pinnedTools = useMemo(() => {
const byId = new Map(visibleTools.map((tool) => [tool.id, tool]));
return pinnedIds.map((id) => byId.get(id)).filter((tool): tool is Tool => tool != null);
}, [pinnedIds, visibleTools]);
const recentTools = useMemo(() => {
const pinnedSet = new Set(pinnedIds);
return recentToolIds
.filter((id) => !pinnedSet.has(id))
.map((id) => visibleTools.find((tool) => tool.id === id))
.filter((tool): tool is Tool => tool != null);
}, [recentToolIds, visibleTools, pinnedIds]);
return (
<AppLayout>
@@ -174,7 +186,11 @@ export function HomePage() {
onRequest={openRequest}
/>
) : activeTab === "all" ? (
<AllTabContent recentTools={recentTools} visibleTools={visibleTools} />
<AllTabContent
pinnedTools={pinnedTools}
recentTools={recentTools}
visibleTools={visibleTools}
/>
) : (
<CategoryGrid groupedTools={groupedTools} />
)}
@@ -387,7 +403,7 @@ function SearchResults({
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{results.map((tool) => (
<ToolCard key={tool.id} tool={tool} variant="descriptive" showModalityBadge />
<ToolCard key={tool.id} tool={tool} variant="descriptive" showModalityBadge showPin />
))}
</div>
<div className="pt-1 text-center">
@@ -405,9 +421,11 @@ function SearchResults({
// ── All Tab: Grouped by section, then by category ───────────────
function AllTabContent({
pinnedTools,
recentTools,
visibleTools,
}: {
pinnedTools: Tool[];
recentTools: Tool[];
visibleTools: Tool[];
}) {
@@ -442,6 +460,20 @@ function AllTabContent({
return (
<div className="space-y-6">
{/* Pinned */}
{pinnedTools.length > 0 && (
<section>
<h2 className="text-[11px] font-semibold uppercase text-muted-foreground/70 tracking-widest mb-2">
{t.homePage.pinned}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{pinnedTools.map((tool) => (
<ToolCard key={tool.id} tool={tool} variant="descriptive" showPin />
))}
</div>
</section>
)}
{/* Recent */}
{recentTools.length > 0 && (
<section>
@@ -513,7 +545,7 @@ function AllTabContent({
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{tools.map((tool) => (
<ToolCard key={tool.id} tool={tool} variant="descriptive" />
<ToolCard key={tool.id} tool={tool} variant="descriptive" showPin />
))}
</div>
</div>
@@ -550,7 +582,7 @@ function CategoryGrid({ groupedTools }: { groupedTools: Map<string, Tool[]> }) {
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{tools.map((tool) => (
<ToolCard key={tool.id} tool={tool} variant="descriptive" />
<ToolCard key={tool.id} tool={tool} variant="descriptive" showPin />
))}
</div>
</section>
+101
View File
@@ -0,0 +1,101 @@
import { create } from "zustand";
import { apiGet, apiPut } from "@/lib/api";
interface PinnedToolsState {
/** Tool ids in display order, newest pin first. */
pinnedTools: string[];
/** The last array the server confirmed; the rollback target on write failure. */
lastConfirmed: string[];
loaded: boolean;
loadError: boolean;
fetch: () => Promise<void>;
pin: (id: string) => void;
unpin: (id: string) => void;
isPinned: (id: string) => boolean;
}
// De-duplicate concurrent fetch() calls (mirrors settings-store).
let inFlight: Promise<void> | null = null;
// Serialize writes: each change appends to this chain, so PUTs run strictly in
// order and the network can never reorder them. A write whose array already
// matches lastConfirmed is skipped (coalesces a pin+unpin burst).
let writeQueue: Promise<void> = Promise.resolve();
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}
function sameArray(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((x, i) => x === b[i]);
}
/**
* Test seam: resolves once every queued write has settled. Lets unit tests
* drain in-flight persistence before resetting the fetch mock between cases,
* so a write enqueued by one test cannot bleed into the next.
*/
export function flushPinnedWrites(): Promise<void> {
return writeQueue;
}
export const usePinnedToolsStore = create<PinnedToolsState>((set, get) => {
function enqueueWrite() {
writeQueue = writeQueue.then(async () => {
const snapshot = get().pinnedTools;
if (sameArray(snapshot, get().lastConfirmed)) return;
try {
await apiPut("/v1/preferences", { pinnedTools: snapshot });
set({ lastConfirmed: snapshot });
} catch {
// Persist failed: roll the optimistic change back to the confirmed set.
set({ pinnedTools: get().lastConfirmed });
}
});
}
return {
pinnedTools: [],
lastConfirmed: [],
loaded: false,
loadError: false,
fetch: async () => {
if (get().loaded && !get().loadError) return;
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const data = await apiGet<{ preferences: Record<string, unknown> }>("/v1/preferences");
const raw = data.preferences?.pinnedTools;
const pins = isStringArray(raw) ? raw : [];
set({ pinnedTools: pins, lastConfirmed: pins, loaded: true, loadError: false });
} catch {
set({ loaded: true, loadError: true });
}
})();
try {
await inFlight;
} finally {
inFlight = null;
}
},
pin: (id) => {
const current = get().pinnedTools;
if (current.includes(id)) return;
set({ pinnedTools: [id, ...current] });
enqueueWrite();
},
unpin: (id) => {
const current = get().pinnedTools;
if (!current.includes(id)) return;
set({ pinnedTools: current.filter((x) => x !== id) });
enqueueWrite();
},
isPinned: (id) => get().pinnedTools.includes(id),
};
});