mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Add Cloudflare Pages deployment for landing page (snapotter.com) and docs (docs.snapotter.com) - Create deploy-landing.yml and update deploy-docs.yml workflows - Update CI to ignore apps/landing/** paths - Fix logo transparency (remove white background) across all apps - Recreate social-preview.png with SnapOtter branding - Update all docs URLs from GitHub Pages to docs.snapotter.com - Update VitePress config: light theme default, fix llms.txt paths - Add .vitepress/cache/ and .env.* to gitignore
229 lines
8.0 KiB
TypeScript
229 lines
8.0 KiB
TypeScript
import type { CategoryInfo, Tool } from "@snapotter/shared";
|
|
import { ANALYTICS_EVENTS, CATEGORIES, TOOLS } from "@snapotter/shared";
|
|
import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { OtterLogo } from "@/components/common/otter-logo";
|
|
import { track } from "@/lib/analytics";
|
|
import { apiGet } from "@/lib/api";
|
|
import { ICON_MAP } from "@/lib/icon-map";
|
|
import { cn } from "@/lib/utils";
|
|
import { useFeaturesStore } from "@/stores/features-store";
|
|
|
|
export function FullscreenGridPage() {
|
|
const [search, setSearch] = useState("");
|
|
const [showDetails, setShowDetails] = useState(true);
|
|
const navigate = useNavigate();
|
|
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
|
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
|
|
const fetchFeatures = useFeaturesStore((s) => s.fetch);
|
|
|
|
useEffect(() => {
|
|
fetchFeatures();
|
|
}, [fetchFeatures]);
|
|
|
|
useEffect(() => {
|
|
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
|
.then((data) => {
|
|
setDisabledTools(
|
|
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
|
);
|
|
setExperimentalEnabled(data.settings.enableExperimentalTools === "true");
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const visibleTools = useMemo(() => {
|
|
return TOOLS.filter((t) => {
|
|
if (disabledTools.includes(t.id)) return false;
|
|
if (t.experimental && !experimentalEnabled) return false;
|
|
return true;
|
|
});
|
|
}, [disabledTools, experimentalEnabled]);
|
|
|
|
const filteredTools = useMemo(() => {
|
|
if (!search) return visibleTools;
|
|
const q = search.toLowerCase();
|
|
return visibleTools.filter(
|
|
(t) =>
|
|
t.name.toLowerCase().includes(q) ||
|
|
t.description.toLowerCase().includes(q) ||
|
|
t.category.toLowerCase().includes(q),
|
|
);
|
|
}, [search, visibleTools]);
|
|
|
|
useEffect(() => {
|
|
if (!search) return;
|
|
const timer = setTimeout(() => {
|
|
track(ANALYTICS_EVENTS.SEARCH, {
|
|
query: search,
|
|
results_count: filteredTools.length,
|
|
});
|
|
}, 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [search, filteredTools.length]);
|
|
|
|
const groupedTools = useMemo(() => {
|
|
const groups = new Map<string, Tool[]>();
|
|
for (const tool of filteredTools) {
|
|
const list = groups.get(tool.category) || [];
|
|
list.push(tool);
|
|
groups.set(tool.category, list);
|
|
}
|
|
return groups;
|
|
}, [filteredTools]);
|
|
|
|
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background text-foreground">
|
|
{/* Top bar */}
|
|
<header className="sticky top-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-4">
|
|
<Link
|
|
to="/"
|
|
className="flex items-center gap-2 text-lg font-bold text-foreground shrink-0"
|
|
>
|
|
<OtterLogo className="h-6 w-6 text-primary" />
|
|
<span className="text-primary">SnapOtter</span>
|
|
</Link>
|
|
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search tools..."
|
|
className="w-full pl-10 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
/>
|
|
</div>
|
|
|
|
{/* Toggle details */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDetails(!showDetails)}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm transition-colors",
|
|
showDetails
|
|
? "bg-primary text-primary-foreground border-primary"
|
|
: "text-muted-foreground hover:bg-muted",
|
|
)}
|
|
>
|
|
{showDetails ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
<span className="hidden sm:inline">
|
|
{showDetails ? "Hide Details" : "Show Details"}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Switch to sidebar view */}
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate("/")}
|
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
|
title="Switch to sidebar view"
|
|
>
|
|
<List className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Sidebar</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Grid */}
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
|
{activeCategories.length === 0 ? (
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
|
<p className="text-lg font-medium">No tools found</p>
|
|
<p className="text-sm mt-1">Try a different search term</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{activeCategories.map((category) => (
|
|
<CategoryCard
|
|
key={category.id}
|
|
category={category}
|
|
tools={groupedTools.get(category.id) || []}
|
|
showDetails={showDetails}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CategoryCard({
|
|
category,
|
|
tools,
|
|
showDetails,
|
|
}: {
|
|
category: CategoryInfo;
|
|
tools: Tool[];
|
|
showDetails: boolean;
|
|
}) {
|
|
const CategoryIcon =
|
|
(ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
|
{/* Header */}
|
|
<div
|
|
className="px-4 py-3 flex items-center gap-3"
|
|
style={{ backgroundColor: `${category.color}15` }}
|
|
>
|
|
<div
|
|
className="p-2 rounded-lg"
|
|
style={{ backgroundColor: `${category.color}25`, color: category.color }}
|
|
>
|
|
<CategoryIcon className="h-5 w-5" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-foreground text-sm">{category.name}</h3>
|
|
</div>
|
|
<span
|
|
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
style={{
|
|
backgroundColor: `${category.color}20`,
|
|
color: category.color,
|
|
}}
|
|
>
|
|
{tools.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Tool list */}
|
|
<div className="p-2">
|
|
{tools.map((tool) => {
|
|
const ToolIcon =
|
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
|
return (
|
|
<Link
|
|
key={tool.id}
|
|
to={tool.route}
|
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
|
>
|
|
<ToolIcon className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-foreground">{tool.name}</p>
|
|
{showDetails && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
{tool.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{tool.experimental && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 font-medium shrink-0">
|
|
Experimental
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|