mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): group home grid, tabs, and breadcrumb by section
This commit is contained in:
@@ -240,7 +240,7 @@ export function App() {
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
|
||||
<Route path="/editor" element={<EditorPage />} />
|
||||
<Route path="/:modality/:toolId" element={<ToolPage />} />
|
||||
<Route path="/:section/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Tool } from "@snapotter/shared";
|
||||
import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
||||
import { PYTHON_SIDECAR_TOOLS, SECTIONS, TOOL_BUNDLE_MAP, toolSection } from "@snapotter/shared";
|
||||
import { Clock, Download, FileImage, Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -15,8 +15,8 @@ interface ToolCardProps {
|
||||
showModalityBadge?: boolean;
|
||||
}
|
||||
|
||||
const MODALITY_COLOR_MAP: Record<string, string> = Object.fromEntries(
|
||||
MODALITIES.map((m) => [m.id, m.color]),
|
||||
const SECTION_COLOR_MAP: Record<string, string> = Object.fromEntries(
|
||||
SECTIONS.map((s) => [s.id, s.color]),
|
||||
);
|
||||
|
||||
export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolCardProps) {
|
||||
@@ -38,17 +38,18 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
|
||||
return bundle?.status === "installed" ? "installed" : "not_installed";
|
||||
}, [isAiTool, tool.id, bundles, installing, queued]);
|
||||
|
||||
const modalityColor = MODALITY_COLOR_MAP[tool.modality] ?? "#6B7280";
|
||||
const section = toolSection(tool);
|
||||
const sectionColor = SECTION_COLOR_MAP[section] ?? "#6B7280";
|
||||
|
||||
const modalityBadge = showModalityBadge ? (
|
||||
const sectionBadge = showModalityBadge ? (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${modalityColor}20`,
|
||||
color: modalityColor,
|
||||
backgroundColor: `${sectionColor}20`,
|
||||
color: sectionColor,
|
||||
}}
|
||||
>
|
||||
{MODALITIES.find((m) => m.id === tool.modality)?.name ?? tool.modality}
|
||||
{SECTIONS.find((s) => s.id === section)?.name ?? section}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
@@ -76,7 +77,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
|
||||
>
|
||||
<div
|
||||
className="p-2 rounded-lg shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: `${modalityColor}12`, color: modalityColor }}
|
||||
style={{ backgroundColor: `${sectionColor}12`, color: sectionColor }}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
@@ -85,7 +86,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</span>
|
||||
{modalityBadge}
|
||||
{sectionBadge}
|
||||
{tool.experimental && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
|
||||
{t.common.experimental}
|
||||
@@ -114,7 +115,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</span>
|
||||
{modalityBadge}
|
||||
{sectionBadge}
|
||||
{tool.experimental && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
|
||||
{t.common.experimental}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function TopNav({
|
||||
{breadcrumb.modality && (
|
||||
<span className={isDark ? "text-[#aaa]" : "text-muted-foreground"}>
|
||||
{breadcrumb.modalityTab ? (
|
||||
<Link to={`/?modality=${breadcrumb.modalityTab}`} className="hover:underline">
|
||||
<Link to={`/?section=${breadcrumb.modalityTab}`} className="hover:underline">
|
||||
{breadcrumb.modality}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -163,7 +163,7 @@ export function TopNav({
|
||||
/>
|
||||
{breadcrumb.modalityTab ? (
|
||||
<Link
|
||||
to={`/?modality=${breadcrumb.modalityTab}`}
|
||||
to={`/?section=${breadcrumb.modalityTab}`}
|
||||
className={cn(
|
||||
"shrink-0 hover:underline",
|
||||
isDark
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Tool } from "@snapotter/shared";
|
||||
import { CATEGORIES, MODALITIES, TOOLS } from "@snapotter/shared";
|
||||
import { CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -20,9 +20,9 @@ interface TabDef {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const COLLAPSE_STORAGE_KEY = "snapotter-collapsed-modalities";
|
||||
const COLLAPSE_STORAGE_KEY = "snapotter-collapsed-sections";
|
||||
|
||||
function getCollapsedModalities(): Set<string> {
|
||||
function getCollapsedSections(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(COLLAPSE_STORAGE_KEY);
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
@@ -31,19 +31,11 @@ function getCollapsedModalities(): Set<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsedModalities(collapsed: Set<string>) {
|
||||
function saveCollapsedSections(collapsed: Set<string>) {
|
||||
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify([...collapsed]));
|
||||
}
|
||||
|
||||
const MODALITY_TAB_ORDER = [
|
||||
{ modalityId: "image", tabKey: "image", label: "Image" },
|
||||
{ modalityId: "video", tabKey: "video", label: "Video" },
|
||||
{ modalityId: "audio", tabKey: "audio", label: "Audio" },
|
||||
{ modalityId: "document", tabKey: "document", label: "PDF" },
|
||||
{ modalityId: "file", tabKey: "data", label: "Data" },
|
||||
];
|
||||
|
||||
const MODALITY_TABS = new Set<string>(["all", ...MODALITY_TAB_ORDER.map((m) => m.tabKey)]);
|
||||
const SECTION_TABS = new Set<string>(["all", ...SECTIONS.map((s) => s.id)]);
|
||||
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -60,12 +52,12 @@ export function HomePage() {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
// Open a specific modality tab when arriving via a breadcrumb link
|
||||
// (/?modality=<tabKey>), then clean the URL so refresh/back doesn't re-pin it.
|
||||
// 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(() => {
|
||||
const m = new URLSearchParams(location.search).get("modality");
|
||||
if (m && MODALITY_TABS.has(m)) {
|
||||
setActiveTab(m);
|
||||
const s = new URLSearchParams(location.search).get("section");
|
||||
if (s && SECTION_TABS.has(s)) {
|
||||
setActiveTab(s);
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [location.search, navigate]);
|
||||
@@ -73,11 +65,10 @@ export function HomePage() {
|
||||
const tabs: TabDef[] = useMemo(
|
||||
() => [
|
||||
{ key: "all", label: t.homePage.all },
|
||||
{ key: "image", label: "Image" },
|
||||
{ key: "video", label: "Video" },
|
||||
{ key: "audio", label: "Audio" },
|
||||
{ key: "document", label: t.homePage.documents },
|
||||
{ key: "data", label: t.homePage.data },
|
||||
...SECTIONS.map((s) => ({
|
||||
key: s.id,
|
||||
label: s.id === "pdf" ? t.homePage.pdf : s.id === "files" ? t.homePage.files : s.name,
|
||||
})),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
@@ -96,8 +87,7 @@ export function HomePage() {
|
||||
|
||||
const tabTools = useMemo(() => {
|
||||
if (activeTab === "all") return visibleTools;
|
||||
const modalityKey = activeTab === "data" ? "file" : activeTab;
|
||||
return visibleTools.filter((tool) => tool.modality === modalityKey);
|
||||
return visibleTools.filter((tool) => toolSection(tool) === activeTab);
|
||||
}, [visibleTools, activeTab]);
|
||||
|
||||
const groupedTools = useMemo(() => {
|
||||
@@ -116,8 +106,8 @@ export function HomePage() {
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: visibleTools.length };
|
||||
for (const tool of visibleTools) {
|
||||
const tabKey = tool.modality === "file" ? "data" : tool.modality;
|
||||
counts[tabKey] = (counts[tabKey] ?? 0) + 1;
|
||||
const sec = toolSection(tool);
|
||||
counts[sec] = (counts[sec] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [visibleTools]);
|
||||
@@ -296,7 +286,7 @@ function SearchResults({
|
||||
);
|
||||
}
|
||||
|
||||
// ── All Tab: Grouped by modality, then by category ───────────────
|
||||
// ── All Tab: Grouped by section, then by category ───────────────
|
||||
|
||||
function AllTabContent({
|
||||
recentTools,
|
||||
@@ -306,29 +296,30 @@ function AllTabContent({
|
||||
visibleTools: Tool[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(getCollapsedModalities);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(getCollapsedSections);
|
||||
|
||||
const toggleModality = useCallback((modalityId: string) => {
|
||||
const toggleSection = useCallback((sectionId: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(modalityId)) next.delete(modalityId);
|
||||
else next.add(modalityId);
|
||||
saveCollapsedModalities(next);
|
||||
if (next.has(sectionId)) next.delete(sectionId);
|
||||
else next.add(sectionId);
|
||||
saveCollapsedSections(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toolsByModality = useMemo(() => {
|
||||
const toolsBySection = useMemo(() => {
|
||||
const map = new Map<string, Map<string, Tool[]>>();
|
||||
for (const tool of visibleTools) {
|
||||
let modMap = map.get(tool.modality);
|
||||
if (!modMap) {
|
||||
modMap = new Map();
|
||||
map.set(tool.modality, modMap);
|
||||
const sec = toolSection(tool);
|
||||
let catMap = map.get(sec);
|
||||
if (!catMap) {
|
||||
catMap = new Map();
|
||||
map.set(sec, catMap);
|
||||
}
|
||||
const existing = modMap.get(tool.category);
|
||||
const existing = catMap.get(tool.category);
|
||||
if (existing) existing.push(tool);
|
||||
else modMap.set(tool.category, [tool]);
|
||||
else catMap.set(tool.category, [tool]);
|
||||
}
|
||||
return map;
|
||||
}, [visibleTools]);
|
||||
@@ -355,37 +346,36 @@ function AllTabContent({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Modality sections */}
|
||||
{MODALITY_TAB_ORDER.map(({ modalityId, label }) => {
|
||||
const categoryMap = toolsByModality.get(modalityId);
|
||||
{/* Section groups */}
|
||||
{SECTIONS.map((sec) => {
|
||||
const categoryMap = toolsBySection.get(sec.id);
|
||||
if (!categoryMap || categoryMap.size === 0) return null;
|
||||
|
||||
const totalCount = [...categoryMap.values()].reduce((sum, arr) => sum + arr.length, 0);
|
||||
const isCollapsed = collapsed.has(modalityId);
|
||||
const modality = MODALITIES.find((m) => m.id === modalityId);
|
||||
const ModalityIcon = modality
|
||||
? (ICON_MAP[modality.icon] as React.ComponentType<{ className?: string }>)
|
||||
: null;
|
||||
const isCollapsed = collapsed.has(sec.id);
|
||||
const SectionIcon = ICON_MAP[sec.icon] as
|
||||
| React.ComponentType<{ className?: string }>
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<section key={modalityId}>
|
||||
<section key={sec.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleModality(modalityId)}
|
||||
onClick={() => toggleSection(sec.id)}
|
||||
className="w-full flex items-center gap-2 py-2 mb-2 border-b border-border/40 group cursor-pointer"
|
||||
>
|
||||
{ModalityIcon && (
|
||||
{SectionIcon && (
|
||||
<div
|
||||
className="p-1 rounded"
|
||||
style={{
|
||||
backgroundColor: `${modality?.color ?? "#6B7280"}15`,
|
||||
color: modality?.color ?? "#6B7280",
|
||||
backgroundColor: `${sec.color}15`,
|
||||
color: sec.color,
|
||||
}}
|
||||
>
|
||||
<ModalityIcon className="h-4 w-4" />
|
||||
<SectionIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-foreground">{label}</span>
|
||||
<span className="text-sm font-semibold text-foreground">{sec.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{totalCount}</span>
|
||||
<div className="flex-1" />
|
||||
<ChevronDown
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
|
||||
import {
|
||||
PYTHON_SIDECAR_TOOLS,
|
||||
SECTIONS,
|
||||
TOOL_BUNDLE_MAP,
|
||||
TOOLS,
|
||||
toolSection,
|
||||
} from "@snapotter/shared";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
@@ -233,11 +239,11 @@ export function ToolPage() {
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!tool) return undefined;
|
||||
const modalityInfo = MODALITIES.find((m) => m.id === tool.modality);
|
||||
const modalityDisplay = modalityInfo?.name ?? tool.modality;
|
||||
const section = toolSection(tool);
|
||||
const sectionInfo = SECTIONS.find((s) => s.id === section);
|
||||
return {
|
||||
modality: modalityDisplay,
|
||||
modalityTab: tool.modality === "file" ? "data" : tool.modality,
|
||||
modality: sectionInfo?.name ?? section,
|
||||
modalityTab: section,
|
||||
toolName: getToolName(t, tool.id, tool.name),
|
||||
};
|
||||
}, [tool, t]);
|
||||
|
||||
@@ -2489,6 +2489,8 @@ export const ar: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2503,6 +2503,8 @@ export const de: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2453,6 +2453,8 @@ export const en = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2485,6 +2485,8 @@ export const es: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2504,6 +2504,8 @@ export const fr: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2486,6 +2486,8 @@ export const hi: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2498,6 +2498,8 @@ export const id: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2497,6 +2497,8 @@ export const it: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2456,6 +2456,8 @@ export const ja: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2441,6 +2441,8 @@ export const ko: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2500,6 +2500,8 @@ export const nl: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2501,6 +2501,8 @@ export const pl: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2497,6 +2497,8 @@ export const ptBR: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2499,6 +2499,8 @@ export const ru: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2496,6 +2496,8 @@ export const sv: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2478,6 +2478,8 @@ export const th: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2501,6 +2501,8 @@ export const tr: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2499,6 +2499,8 @@ export const uk: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2498,6 +2498,8 @@ export const vi: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2431,6 +2431,8 @@ export const zhCN: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
@@ -2429,6 +2429,8 @@ export const zhTW: TranslationKeys = {
|
||||
all: "All",
|
||||
documents: "PDF",
|
||||
data: "Data",
|
||||
pdf: "PDF",
|
||||
files: "Files",
|
||||
toolCount: "{count} tools",
|
||||
heading: "SnapOtter Tools",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user