mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
@@ -2952,6 +2952,7 @@ export const ar: TranslationKeys = {
|
||||
allTools: "جميع الأدوات",
|
||||
searchPlaceholder: "بحث في {count} أداة...",
|
||||
recent: "الأخيرة",
|
||||
pinned: "مثبّت",
|
||||
popular: "الشائعة",
|
||||
browseByCategory: "تصفح حسب الفئة",
|
||||
gettingStarted: "البدء",
|
||||
@@ -3843,7 +3844,8 @@ export const ar: TranslationKeys = {
|
||||
settings: "الإعدادات",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "إضافة إلى المفضلة",
|
||||
pin: "تثبيت",
|
||||
unpin: "إلغاء التثبيت",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "الأدوات",
|
||||
|
||||
@@ -2976,6 +2976,7 @@ export const de: TranslationKeys = {
|
||||
allTools: "Alle Werkzeuge",
|
||||
searchPlaceholder: "{count} Werkzeuge durchsuchen...",
|
||||
recent: "Zuletzt verwendet",
|
||||
pinned: "Angeheftet",
|
||||
popular: "Beliebt",
|
||||
browseByCategory: "Nach Kategorie durchsuchen",
|
||||
gettingStarted: "Erste Schritte",
|
||||
@@ -3891,7 +3892,8 @@ export const de: TranslationKeys = {
|
||||
settings: "Einstellungen",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Zu Favoriten hinzufügen",
|
||||
pin: "Anheften",
|
||||
unpin: "Lösen",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Werkzeuge",
|
||||
|
||||
@@ -2918,6 +2918,7 @@ export const en = {
|
||||
allTools: "All Tools",
|
||||
searchPlaceholder: "Search {count} tools...",
|
||||
recent: "Recent",
|
||||
pinned: "Pinned",
|
||||
popular: "Popular",
|
||||
browseByCategory: "Browse by Category",
|
||||
gettingStarted: "Getting Started",
|
||||
@@ -3814,7 +3815,8 @@ export const en = {
|
||||
settings: "Settings",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Add to favourites",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Tools",
|
||||
|
||||
@@ -2957,6 +2957,7 @@ export const es: TranslationKeys = {
|
||||
allTools: "Todas las herramientas",
|
||||
searchPlaceholder: "Buscar en {count} herramientas...",
|
||||
recent: "Recientes",
|
||||
pinned: "Fijados",
|
||||
popular: "Popular",
|
||||
browseByCategory: "Explorar por categoría",
|
||||
gettingStarted: "Primeros pasos",
|
||||
@@ -3869,7 +3870,8 @@ export const es: TranslationKeys = {
|
||||
settings: "Configuración",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Agregar a favoritos",
|
||||
pin: "Fijar",
|
||||
unpin: "Quitar",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Herramientas",
|
||||
|
||||
@@ -2981,6 +2981,7 @@ export const fr: TranslationKeys = {
|
||||
allTools: "Tous les outils",
|
||||
searchPlaceholder: "Rechercher parmi {count} outils...",
|
||||
recent: "Récents",
|
||||
pinned: "Épinglés",
|
||||
popular: "Populaires",
|
||||
browseByCategory: "Parcourir par catégorie",
|
||||
gettingStarted: "Pour commencer",
|
||||
@@ -3893,7 +3894,8 @@ export const fr: TranslationKeys = {
|
||||
settings: "Paramètres",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Ajouter aux favoris",
|
||||
pin: "Épingler",
|
||||
unpin: "Détacher",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Outils",
|
||||
|
||||
@@ -2784,6 +2784,7 @@ export const hi: TranslationKeys = {
|
||||
allTools: "सभी टूल्स",
|
||||
searchPlaceholder: "{count} टूल में खोजें...",
|
||||
recent: "हाल के",
|
||||
pinned: "पिन किए गए",
|
||||
popular: "लोकप्रिय",
|
||||
browseByCategory: "श्रेणी के अनुसार देखें",
|
||||
gettingStarted: "शुरू करें",
|
||||
@@ -3673,7 +3674,8 @@ export const hi: TranslationKeys = {
|
||||
settings: "सेटिंग्स",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "पसंदीदा में जोड़ें",
|
||||
pin: "पिन करें",
|
||||
unpin: "अनपिन करें",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "टूल्स",
|
||||
|
||||
@@ -2965,6 +2965,7 @@ export const id: TranslationKeys = {
|
||||
allTools: "Semua Alat",
|
||||
searchPlaceholder: "Cari {count} alat...",
|
||||
recent: "Terbaru",
|
||||
pinned: "Disematkan",
|
||||
popular: "Populer",
|
||||
browseByCategory: "Jelajahi berdasarkan Kategori",
|
||||
gettingStarted: "Memulai",
|
||||
@@ -3868,7 +3869,8 @@ export const id: TranslationKeys = {
|
||||
settings: "Pengaturan",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Tambah ke favorit",
|
||||
pin: "Sematkan",
|
||||
unpin: "Lepas sematan",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Alat",
|
||||
|
||||
@@ -2973,6 +2973,7 @@ export const it: TranslationKeys = {
|
||||
allTools: "Tutti gli strumenti",
|
||||
searchPlaceholder: "Cerca tra {count} strumenti...",
|
||||
recent: "Recenti",
|
||||
pinned: "Fissati",
|
||||
popular: "Popolari",
|
||||
browseByCategory: "Esplora per categoria",
|
||||
gettingStarted: "Per iniziare",
|
||||
@@ -3883,7 +3884,8 @@ export const it: TranslationKeys = {
|
||||
settings: "Impostazioni",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Aggiungi ai preferiti",
|
||||
pin: "Fissa",
|
||||
unpin: "Rimuovi",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Strumenti",
|
||||
|
||||
@@ -2923,6 +2923,7 @@ export const ja: TranslationKeys = {
|
||||
allTools: "すべてのツール",
|
||||
searchPlaceholder: "{count} 個のツールを検索...",
|
||||
recent: "最近",
|
||||
pinned: "ピン留め",
|
||||
popular: "人気",
|
||||
browseByCategory: "カテゴリで探す",
|
||||
gettingStarted: "はじめに",
|
||||
@@ -3820,7 +3821,8 @@ export const ja: TranslationKeys = {
|
||||
settings: "設定",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "お気に入りに追加",
|
||||
pin: "ピン留め",
|
||||
unpin: "ピン留め解除",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "ツール",
|
||||
|
||||
@@ -2905,6 +2905,7 @@ export const ko: TranslationKeys = {
|
||||
allTools: "모든 도구",
|
||||
searchPlaceholder: "{count}개 도구 검색...",
|
||||
recent: "최근",
|
||||
pinned: "고정됨",
|
||||
popular: "인기",
|
||||
browseByCategory: "카테고리별 탐색",
|
||||
gettingStarted: "시작하기",
|
||||
@@ -3800,7 +3801,8 @@ export const ko: TranslationKeys = {
|
||||
settings: "설정",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "즐겨찾기에 추가",
|
||||
pin: "고정",
|
||||
unpin: "고정 해제",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "도구",
|
||||
|
||||
@@ -2973,6 +2973,7 @@ export const nl: TranslationKeys = {
|
||||
allTools: "Alle tools",
|
||||
searchPlaceholder: "Zoeken in {count} tools...",
|
||||
recent: "Recent",
|
||||
pinned: "Vastgezet",
|
||||
popular: "Populair",
|
||||
browseByCategory: "Bladeren op categorie",
|
||||
gettingStarted: "Aan de slag",
|
||||
@@ -3878,7 +3879,8 @@ export const nl: TranslationKeys = {
|
||||
settings: "Instellingen",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Aan favorieten toevoegen",
|
||||
pin: "Vastzetten",
|
||||
unpin: "Losmaken",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Tools",
|
||||
|
||||
@@ -2971,6 +2971,7 @@ export const pl: TranslationKeys = {
|
||||
allTools: "Wszystkie narzędzia",
|
||||
searchPlaceholder: "Szukaj wśród {count} narzędzi...",
|
||||
recent: "Ostatnie",
|
||||
pinned: "Przypięte",
|
||||
popular: "Popularne",
|
||||
browseByCategory: "Przeglądaj wg kategorii",
|
||||
gettingStarted: "Pierwsze kroki",
|
||||
@@ -3884,7 +3885,8 @@ export const pl: TranslationKeys = {
|
||||
settings: "Ustawienia",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Dodaj do ulubionych",
|
||||
pin: "Przypnij",
|
||||
unpin: "Odepnij",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Narzędzia",
|
||||
|
||||
@@ -2967,6 +2967,7 @@ export const ptBR: TranslationKeys = {
|
||||
allTools: "Todas as ferramentas",
|
||||
searchPlaceholder: "Buscar em {count} ferramentas...",
|
||||
recent: "Recentes",
|
||||
pinned: "Fixados",
|
||||
popular: "Populares",
|
||||
browseByCategory: "Navegar por categoria",
|
||||
gettingStarted: "Primeiros passos",
|
||||
@@ -3875,7 +3876,8 @@ export const ptBR: TranslationKeys = {
|
||||
settings: "Configurações",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Adicionar aos favoritos",
|
||||
pin: "Fixar",
|
||||
unpin: "Desafixar",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Ferramentas",
|
||||
|
||||
@@ -2967,6 +2967,7 @@ export const ru: TranslationKeys = {
|
||||
allTools: "Все инструменты",
|
||||
searchPlaceholder: "Поиск среди {count} инструментов...",
|
||||
recent: "Недавние",
|
||||
pinned: "Закреплённые",
|
||||
popular: "Популярные",
|
||||
browseByCategory: "По категориям",
|
||||
gettingStarted: "Начало работы",
|
||||
@@ -3872,7 +3873,8 @@ export const ru: TranslationKeys = {
|
||||
settings: "Настройки",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Добавить в избранное",
|
||||
pin: "Закрепить",
|
||||
unpin: "Открепить",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Инструменты",
|
||||
|
||||
@@ -2964,6 +2964,7 @@ export const sv: TranslationKeys = {
|
||||
allTools: "Alla verktyg",
|
||||
searchPlaceholder: "Sök bland {count} verktyg...",
|
||||
recent: "Senaste",
|
||||
pinned: "Fästa",
|
||||
popular: "Populära",
|
||||
browseByCategory: "Utforska per kategori",
|
||||
gettingStarted: "Kom igång",
|
||||
@@ -3864,7 +3865,8 @@ export const sv: TranslationKeys = {
|
||||
settings: "Inställningar",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Lägg till i favoriter",
|
||||
pin: "Fäst",
|
||||
unpin: "Ta bort",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Verktyg",
|
||||
|
||||
@@ -2937,6 +2937,7 @@ export const th: TranslationKeys = {
|
||||
allTools: "เครื่องมือทั้งหมด",
|
||||
searchPlaceholder: "ค้นหา {count} เครื่องมือ...",
|
||||
recent: "ล่าสุด",
|
||||
pinned: "ที่ปักหมุด",
|
||||
popular: "ยอดนิยม",
|
||||
browseByCategory: "เรียกดูตามหมวดหมู่",
|
||||
gettingStarted: "เริ่มต้นใช้งาน",
|
||||
@@ -3823,7 +3824,8 @@ export const th: TranslationKeys = {
|
||||
settings: "ตั้งค่า",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "เพิ่มในรายการโปรด",
|
||||
pin: "ปักหมุด",
|
||||
unpin: "เลิกปักหมุด",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "เครื่องมือ",
|
||||
|
||||
@@ -2968,6 +2968,7 @@ export const tr: TranslationKeys = {
|
||||
allTools: "Tüm Araçlar",
|
||||
searchPlaceholder: "{count} araç içinde ara...",
|
||||
recent: "Son Kullanılan",
|
||||
pinned: "Sabitlenenler",
|
||||
popular: "Popüler",
|
||||
browseByCategory: "Kategoriye Göre Göz At",
|
||||
gettingStarted: "Başlarken",
|
||||
@@ -3875,7 +3876,8 @@ export const tr: TranslationKeys = {
|
||||
settings: "Ayarlar",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Favorilere ekle",
|
||||
pin: "Sabitle",
|
||||
unpin: "Kaldır",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Araçlar",
|
||||
|
||||
@@ -2969,6 +2969,7 @@ export const uk: TranslationKeys = {
|
||||
allTools: "Усі інструменти",
|
||||
searchPlaceholder: "Пошук серед {count} інструментів...",
|
||||
recent: "Нещодавні",
|
||||
pinned: "Закріплені",
|
||||
popular: "Популярні",
|
||||
browseByCategory: "Перегляд за категорією",
|
||||
gettingStarted: "Початок роботи",
|
||||
@@ -3873,7 +3874,8 @@ export const uk: TranslationKeys = {
|
||||
settings: "Налаштування",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Додати до обраного",
|
||||
pin: "Закріпити",
|
||||
unpin: "Відкріпити",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Інструменти",
|
||||
|
||||
@@ -2965,6 +2965,7 @@ export const vi: TranslationKeys = {
|
||||
allTools: "Tất cả công cụ",
|
||||
searchPlaceholder: "Tìm kiếm {count} công cụ...",
|
||||
recent: "Gần đây",
|
||||
pinned: "Đã ghim",
|
||||
popular: "Phổ biến",
|
||||
browseByCategory: "Duyệt theo danh mục",
|
||||
gettingStarted: "Bắt đầu",
|
||||
@@ -3864,7 +3865,8 @@ export const vi: TranslationKeys = {
|
||||
settings: "Cài đặt",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "Thêm vào yêu thích",
|
||||
pin: "Ghim",
|
||||
unpin: "Bỏ ghim",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "Công cụ",
|
||||
|
||||
@@ -2725,6 +2725,7 @@ export const zhCN: TranslationKeys = {
|
||||
allTools: "所有工具",
|
||||
searchPlaceholder: "搜索 {count} 个工具...",
|
||||
recent: "最近",
|
||||
pinned: "已固定",
|
||||
popular: "热门",
|
||||
browseByCategory: "按分类浏览",
|
||||
gettingStarted: "开始使用",
|
||||
@@ -3606,7 +3607,8 @@ export const zhCN: TranslationKeys = {
|
||||
settings: "设置",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "添加到收藏",
|
||||
pin: "固定",
|
||||
unpin: "取消固定",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "工具",
|
||||
|
||||
@@ -2724,6 +2724,7 @@ export const zhTW: TranslationKeys = {
|
||||
allTools: "全部工具",
|
||||
searchPlaceholder: "搜尋 {count} 個工具...",
|
||||
recent: "最近使用",
|
||||
pinned: "已釘選",
|
||||
popular: "熱門",
|
||||
browseByCategory: "依分類瀏覽",
|
||||
gettingStarted: "快速入門",
|
||||
@@ -3607,7 +3608,8 @@ export const zhTW: TranslationKeys = {
|
||||
settings: "設定",
|
||||
},
|
||||
toolCard: {
|
||||
addToFavourites: "加入收藏",
|
||||
pin: "釘選",
|
||||
unpin: "取消釘選",
|
||||
},
|
||||
appLayout: {
|
||||
mobileNavTools: "工具",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "./helpers";
|
||||
|
||||
// Mutates the shared per-user `pinnedTools` preference on the server, so it
|
||||
// pins and then unpins within the single test to leave state clean.
|
||||
test.describe("Pin tools", () => {
|
||||
test("pin a tool, persist across reload, then unpin", async ({ loggedInPage: page }) => {
|
||||
// Resize lives under Image > Essentials on the All tab (default).
|
||||
const pinToggle = page.getByTestId("pin-toggle-resize").first();
|
||||
await expect(pinToggle).toBeVisible();
|
||||
|
||||
// Not pinned yet: no Pinned section heading.
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toHaveCount(0);
|
||||
|
||||
// Pin it.
|
||||
await pinToggle.click();
|
||||
|
||||
// The Pinned section appears with the Resize card.
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toBeVisible();
|
||||
|
||||
// Reload: the pin persisted server-side and re-hydrates.
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toBeVisible();
|
||||
|
||||
// Unpin (there are two Resize pin toggles now: one in Pinned, one in the
|
||||
// Image group). Either flips the shared state; click the first and assert
|
||||
// the section is gone.
|
||||
await page.getByTestId("pin-toggle-resize").first().click();
|
||||
await expect(page.getByRole("heading", { name: /^Pinned$/i })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// fetch + localStorage must be stubbed before the modules under test load.
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => storageMap.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => storageMap.set(k, v),
|
||||
removeItem: (k: string) => storageMap.delete(k),
|
||||
clear: () => storageMap.clear(),
|
||||
get length() {
|
||||
return storageMap.size;
|
||||
},
|
||||
key: () => null,
|
||||
});
|
||||
|
||||
import { flushPinnedWrites, usePinnedToolsStore } from "@/stores/pinned-tools-store";
|
||||
|
||||
function okJson(data: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(data),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
function failResponse(status: number) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status,
|
||||
json: () => Promise.reject(new Error("no body")),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
describe("pinned-tools store", () => {
|
||||
beforeEach(async () => {
|
||||
// Drain any write queued by a prior test before swapping the fetch mock,
|
||||
// so a leaked PUT cannot run against the next test's fresh mock.
|
||||
await flushPinnedWrites();
|
||||
fetchMock.mockReset();
|
||||
usePinnedToolsStore.setState({
|
||||
pinnedTools: [],
|
||||
lastConfirmed: [],
|
||||
loaded: false,
|
||||
loadError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("fetch loads pinnedTools from /v1/preferences", async () => {
|
||||
fetchMock.mockReturnValueOnce(okJson({ preferences: { pinnedTools: ["resize", "compress"] } }));
|
||||
await usePinnedToolsStore.getState().fetch();
|
||||
const s = usePinnedToolsStore.getState();
|
||||
expect(s.pinnedTools).toEqual(["resize", "compress"]);
|
||||
expect(s.lastConfirmed).toEqual(["resize", "compress"]);
|
||||
expect(s.loaded).toBe(true);
|
||||
expect(s.loadError).toBe(false);
|
||||
});
|
||||
|
||||
it("fetch defaults to [] when pinnedTools is missing or malformed", async () => {
|
||||
fetchMock.mockReturnValueOnce(okJson({ preferences: { pinnedTools: "not-an-array" } }));
|
||||
await usePinnedToolsStore.getState().fetch();
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual([]);
|
||||
expect(usePinnedToolsStore.getState().loaded).toBe(true);
|
||||
});
|
||||
|
||||
it("fetch sets loadError on network failure", async () => {
|
||||
fetchMock.mockReturnValueOnce(failResponse(500));
|
||||
await usePinnedToolsStore.getState().fetch();
|
||||
expect(usePinnedToolsStore.getState().loaded).toBe(true);
|
||||
expect(usePinnedToolsStore.getState().loadError).toBe(true);
|
||||
});
|
||||
|
||||
it("pin prepends and updates state synchronously", () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["compress"], lastConfirmed: ["compress"] });
|
||||
fetchMock.mockReturnValue(okJson({ ok: true }));
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize", "compress"]);
|
||||
});
|
||||
|
||||
it("pin is a no-op when the tool is already pinned", () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["resize"], lastConfirmed: ["resize"] });
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize"]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unpin removes the tool", () => {
|
||||
usePinnedToolsStore.setState({
|
||||
pinnedTools: ["resize", "compress"],
|
||||
lastConfirmed: ["resize", "compress"],
|
||||
});
|
||||
fetchMock.mockReturnValue(okJson({ ok: true }));
|
||||
usePinnedToolsStore.getState().unpin("resize");
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["compress"]);
|
||||
});
|
||||
|
||||
it("pin persists the full array via PUT /v1/preferences", async () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: [], lastConfirmed: [] });
|
||||
fetchMock.mockReturnValue(okJson({ ok: true }));
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/preferences");
|
||||
expect(opts.method).toBe("PUT");
|
||||
expect(JSON.parse(opts.body)).toEqual({ pinnedTools: ["resize"] });
|
||||
await vi.waitFor(() =>
|
||||
expect(usePinnedToolsStore.getState().lastConfirmed).toEqual(["resize"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rolls back to lastConfirmed when the PUT fails", async () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["compress"], lastConfirmed: ["compress"] });
|
||||
fetchMock.mockReturnValueOnce(failResponse(500));
|
||||
usePinnedToolsStore.getState().pin("resize");
|
||||
// Optimistic update applied immediately.
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize", "compress"]);
|
||||
// Rolls back once the failed write settles.
|
||||
await vi.waitFor(() =>
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["compress"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("isPinned reflects current state", () => {
|
||||
usePinnedToolsStore.setState({ pinnedTools: ["resize"], lastConfirmed: ["resize"] });
|
||||
expect(usePinnedToolsStore.getState().isPinned("resize")).toBe(true);
|
||||
expect(usePinnedToolsStore.getState().isPinned("compress")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { TOOLS } from "@snapotter/shared";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolCard } from "@/components/common/tool-card";
|
||||
import { usePinnedToolsStore } from "@/stores/pinned-tools-store";
|
||||
|
||||
// Make the store's optimistic persistence a no-op so the test stays server-free.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
apiGet: vi.fn(() => Promise.resolve({ preferences: {} })),
|
||||
apiPut: vi.fn(() => Promise.resolve({ ok: true })),
|
||||
}));
|
||||
|
||||
const resize = TOOLS.find((tool) => tool.id === "resize");
|
||||
if (!resize) throw new Error("resize tool missing from TOOLS");
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
usePinnedToolsStore.setState({
|
||||
pinnedTools: [],
|
||||
lastConfirmed: [],
|
||||
loaded: true,
|
||||
loadError: false,
|
||||
});
|
||||
});
|
||||
|
||||
function renderCard(showPin: boolean) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ToolCard tool={resize} variant="descriptive" showPin={showPin} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ToolCard pin button", () => {
|
||||
it("renders no pin button unless showPin is set", () => {
|
||||
renderCard(false);
|
||||
expect(screen.queryByTestId("pin-toggle-resize")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles pinned state and aria label when clicked", () => {
|
||||
renderCard(true);
|
||||
const btn = screen.getByTestId("pin-toggle-resize");
|
||||
expect(btn.getAttribute("aria-label")).toBe("Pin");
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual(["resize"]);
|
||||
const pinnedBtn = screen.getByTestId("pin-toggle-resize");
|
||||
expect(pinnedBtn.getAttribute("aria-label")).toBe("Unpin");
|
||||
expect(pinnedBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
fireEvent.click(pinnedBtn);
|
||||
expect(usePinnedToolsStore.getState().pinnedTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user