mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): stronger modality section visual hierarchy in tool catalog
This commit is contained in:
@@ -54,17 +54,26 @@ export function ToolPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 pb-4 flex-1">
|
<div className="px-3 pb-4 flex-1">
|
||||||
{MODALITIES.filter((m) => m.id !== "file" && groupedByModality.has(m.id)).map(
|
{MODALITIES.filter((m) => m.id !== "file" && groupedByModality.has(m.id)).map(
|
||||||
(modality) => {
|
(modality, idx, arr) => {
|
||||||
const ModalityIcon = ICON_MAP[modality.icon] as React.ComponentType<{
|
const ModalityIcon = ICON_MAP[modality.icon] as React.ComponentType<{
|
||||||
className?: string;
|
className?: string;
|
||||||
}>;
|
}>;
|
||||||
const categoryMap = groupedByModality.get(modality.id);
|
const categoryMap = groupedByModality.get(modality.id);
|
||||||
if (!categoryMap) return null;
|
if (!categoryMap) return null;
|
||||||
|
const isLast = idx === arr.length - 1;
|
||||||
return (
|
return (
|
||||||
<div key={modality.id} className="mb-5">
|
<div key={modality.id} className={isLast ? "" : "mb-2"}>
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
{idx > 0 && <hr className="border-border mb-4" />}
|
||||||
{ModalityIcon && <ModalityIcon className="h-4 w-4 text-foreground/70 shrink-0" />}
|
<div
|
||||||
<h2 className="text-xs font-bold uppercase text-foreground/70 tracking-wider">
|
className="flex items-center gap-2 mt-4 mb-3 pl-2.5 border-l-[3px] rounded-sm"
|
||||||
|
style={{ borderColor: modality.color }}
|
||||||
|
>
|
||||||
|
{ModalityIcon && (
|
||||||
|
<span className="shrink-0" style={{ color: modality.color }}>
|
||||||
|
<ModalityIcon className="h-4.5 w-4.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h2 className="text-sm font-semibold text-foreground tracking-wide">
|
||||||
{getModalityName(
|
{getModalityName(
|
||||||
t,
|
t,
|
||||||
modality.id,
|
modality.id,
|
||||||
@@ -73,8 +82,8 @@ export function ToolPanel() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{CATEGORIES.filter((cat) => categoryMap.has(cat.id)).map((category) => (
|
{CATEGORIES.filter((cat) => categoryMap.has(cat.id)).map((category) => (
|
||||||
<div key={category.id} className="mb-4">
|
<div key={category.id} className="mb-3">
|
||||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
<h3 className="text-xs font-medium text-muted-foreground tracking-wider mb-1.5 pl-1">
|
||||||
{getCategoryName(t, category.id, category.name)}
|
{getCategoryName(t, category.id, category.name)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CategoryInfo, Tool } from "@snapotter/shared";
|
import type { CategoryInfo, Modality, Tool } from "@snapotter/shared";
|
||||||
import { ANALYTICS_EVENTS, CATEGORIES, TOOLS } from "@snapotter/shared";
|
import { ANALYTICS_EVENTS, CATEGORIES, MODALITIES, TOOLS } from "@snapotter/shared";
|
||||||
import {
|
import {
|
||||||
AudioLines,
|
AudioLines,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -23,7 +23,7 @@ import { useMobile } from "@/hooks/use-mobile";
|
|||||||
import { track } from "@/lib/analytics";
|
import { track } from "@/lib/analytics";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import { ICON_MAP } from "@/lib/icon-map";
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
import { getCategoryName, getModalityName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFeaturesStore } from "@/stores/features-store";
|
import { useFeaturesStore } from "@/stores/features-store";
|
||||||
|
|
||||||
@@ -100,6 +100,25 @@ export function FullscreenGridPage() {
|
|||||||
|
|
||||||
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
|
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
|
||||||
|
|
||||||
|
/** Map each modality to its list of active categories (for "All" tab grouping). */
|
||||||
|
const modalitySections = useMemo(() => {
|
||||||
|
if (modalityTab !== "all") return null;
|
||||||
|
const catToModality = new Map<string, Modality>();
|
||||||
|
for (const tool of filteredTools) {
|
||||||
|
const key = tool.modality === "file" ? ("document" as Modality) : tool.modality;
|
||||||
|
if (!catToModality.has(tool.category)) {
|
||||||
|
catToModality.set(tool.category, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sections: { modality: (typeof MODALITIES)[number]; categories: CategoryInfo[] }[] = [];
|
||||||
|
for (const mod of MODALITIES) {
|
||||||
|
if (mod.id === "file") continue;
|
||||||
|
const cats = activeCategories.filter((c) => catToModality.get(c.id) === mod.id);
|
||||||
|
if (cats.length > 0) sections.push({ modality: mod, categories: cats });
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}, [modalityTab, filteredTools, activeCategories]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-h-screen bg-background text-foreground", isMobile && "pb-20")}>
|
<div className={cn("min-h-screen bg-background text-foreground", isMobile && "pb-20")}>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
@@ -190,17 +209,104 @@ export function FullscreenGridPage() {
|
|||||||
<p className="text-lg font-medium">{t.fullscreenGrid.noToolsFound}</p>
|
<p className="text-lg font-medium">{t.fullscreenGrid.noToolsFound}</p>
|
||||||
<p className="text-sm mt-1">{t.fullscreenGrid.tryDifferent}</p>
|
<p className="text-sm mt-1">{t.fullscreenGrid.tryDifferent}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : modalitySections ? (
|
||||||
|
/* "All" tab: render with modality section headers */
|
||||||
|
<div className="space-y-10">
|
||||||
|
{modalitySections.map((section) => {
|
||||||
|
const SectionIcon = ICON_MAP[section.modality.icon] as React.ComponentType<{
|
||||||
|
className?: string;
|
||||||
|
}>;
|
||||||
|
return (
|
||||||
|
<section key={section.modality.id}>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-5 pb-3 border-b-2"
|
||||||
|
style={{ borderColor: section.modality.color }}
|
||||||
|
>
|
||||||
|
{SectionIcon && (
|
||||||
|
<div
|
||||||
|
className="p-2 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${section.modality.color}15`,
|
||||||
|
color: section.modality.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SectionIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{getModalityName(
|
||||||
|
t,
|
||||||
|
section.modality.id,
|
||||||
|
section.modality.id === "document"
|
||||||
|
? "Documents & Files"
|
||||||
|
: section.modality.name,
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
{activeCategories.map((category) => (
|
{section.categories.map((category) => (
|
||||||
<CategoryCard
|
<CategoryCard
|
||||||
key={category.id}
|
key={category.id}
|
||||||
category={category}
|
category={category}
|
||||||
tools={groupedTools.get(category.id) || []}
|
tools={groupedTools.get(category.id) || []}
|
||||||
showDetails={showDetails}
|
showDetails={showDetails}
|
||||||
|
accentColor={section.modality.color}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Specific modality tab selected */
|
||||||
|
<div>
|
||||||
|
{(() => {
|
||||||
|
const activeMod = MODALITIES.find((m) => m.id === modalityTab);
|
||||||
|
const ModIcon = activeMod
|
||||||
|
? (ICON_MAP[activeMod.icon] as React.ComponentType<{ className?: string }>)
|
||||||
|
: null;
|
||||||
|
return activeMod ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-6 pb-3 border-b-2"
|
||||||
|
style={{ borderColor: activeMod.color }}
|
||||||
|
>
|
||||||
|
{ModIcon && (
|
||||||
|
<div
|
||||||
|
className="p-2 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${activeMod.color}15`,
|
||||||
|
color: activeMod.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{getModalityName(
|
||||||
|
t,
|
||||||
|
activeMod.id,
|
||||||
|
activeMod.id === "document" ? "Documents & Files" : activeMod.name,
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{activeCategories.map((category) => {
|
||||||
|
const activeMod = MODALITIES.find((m) => m.id === modalityTab);
|
||||||
|
return (
|
||||||
|
<CategoryCard
|
||||||
|
key={category.id}
|
||||||
|
category={category}
|
||||||
|
tools={groupedTools.get(category.id) || []}
|
||||||
|
showDetails={showDetails}
|
||||||
|
accentColor={activeMod?.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
{isMobile && <MobileBottomNav />}
|
{isMobile && <MobileBottomNav />}
|
||||||
@@ -212,17 +318,22 @@ function CategoryCard({
|
|||||||
category,
|
category,
|
||||||
tools,
|
tools,
|
||||||
showDetails,
|
showDetails,
|
||||||
|
accentColor,
|
||||||
}: {
|
}: {
|
||||||
category: CategoryInfo;
|
category: CategoryInfo;
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
showDetails: boolean;
|
showDetails: boolean;
|
||||||
|
accentColor?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const CategoryIcon =
|
const CategoryIcon =
|
||||||
(ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid;
|
(ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
<div
|
||||||
|
className="rounded-xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
style={accentColor ? { borderLeftWidth: 3, borderLeftColor: accentColor } : undefined}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="px-4 py-3 flex items-center gap-3"
|
className="px-4 py-3 flex items-center gap-3"
|
||||||
|
|||||||
Reference in New Issue
Block a user