Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51304d782a | |||
| 6e81a7eb36 | |||
| 8bee570cb2 | |||
| ed06d269c6 | |||
| 0214253b48 | |||
| afced01ba6 | |||
| 8812e5f5e3 | |||
| 01055ded02 | |||
| 01593dcbdc | |||
| afdd26cb59 | |||
| 4b4758a60f | |||
| f9360c15e3 |
@@ -28,6 +28,7 @@ export default function App() {
|
|||||||
const [tab, setTab] = useState<Tab>("products");
|
const [tab, setTab] = useState<Tab>("products");
|
||||||
const [pending, setPending] = useState<number | null>(null);
|
const [pending, setPending] = useState<number | null>(null);
|
||||||
const [view, setView] = useState<View>({ name: "list" });
|
const [view, setView] = useState<View>({ name: "list" });
|
||||||
|
const [navIds, setNavIds] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed) return;
|
if (!authed) return;
|
||||||
@@ -190,9 +191,19 @@ export default function App() {
|
|||||||
) : tab === "submissions" ? (
|
) : tab === "submissions" ? (
|
||||||
<SubmissionsPage onPending={setPending} />
|
<SubmissionsPage onPending={setPending} />
|
||||||
) : view.name === "list" ? (
|
) : view.name === "list" ? (
|
||||||
<ProductList onOpen={(id) => setView({ name: "detail", id })} />
|
<ProductList
|
||||||
|
onOpen={(id, ids) => {
|
||||||
|
setNavIds(ids);
|
||||||
|
setView({ name: "detail", id });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProductDetail id={view.id} onBack={() => setView({ name: "list" })} />
|
<ProductDetail
|
||||||
|
id={view.id}
|
||||||
|
ids={navIds}
|
||||||
|
onNavigate={(id) => setView({ name: "detail", id })}
|
||||||
|
onBack={() => setView({ name: "list" })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function ApiKeysPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function AuditLogPage() {
|
|||||||
const pages = Math.max(1, Math.ceil(total / size));
|
const pages = Math.max(1, Math.ceil(total / size));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
|
||||||
<ScrollText className="h-5 w-5 text-emerald-600" /> 操作日志
|
<ScrollText className="h-5 w-5 text-emerald-600" /> 操作日志
|
||||||
<span className="text-sm font-normal text-gray-400">共 {total} 条</span>
|
<span className="text-sm font-normal text-gray-400">共 {total} 条</span>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function BrandsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -88,10 +90,18 @@ const inputCls =
|
|||||||
export default function ProductDetail({
|
export default function ProductDetail({
|
||||||
id,
|
id,
|
||||||
onBack,
|
onBack,
|
||||||
|
ids = [],
|
||||||
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
ids?: string[];
|
||||||
|
onNavigate?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const navIndex = ids.indexOf(id);
|
||||||
|
const prevId = navIndex > 0 ? ids[navIndex - 1] : null;
|
||||||
|
const nextId =
|
||||||
|
navIndex >= 0 && navIndex < ids.length - 1 ? ids[navIndex + 1] : null;
|
||||||
const [d, setD] = useState<Detail | null>(null);
|
const [d, setD] = useState<Detail | null>(null);
|
||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
@@ -226,12 +236,35 @@ export default function ProductDetail({
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-5">
|
<div className="mx-auto max-w-5xl space-y-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={onBack}
|
<button
|
||||||
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
onClick={onBack}
|
||||||
>
|
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||||
<ArrowLeft className="h-4 w-4" /> 返回列表
|
>
|
||||||
</button>
|
<ArrowLeft className="h-4 w-4" /> 返回列表
|
||||||
|
</button>
|
||||||
|
{ids.length > 1 && navIndex >= 0 && (
|
||||||
|
<div className="ml-2 flex items-center gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => prevId && onNavigate?.(prevId)}
|
||||||
|
disabled={!prevId}
|
||||||
|
className="flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> 上一个
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{navIndex + 1} / {ids.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => nextId && onNavigate?.(nextId)}
|
||||||
|
disabled={!nextId}
|
||||||
|
className="flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
下一个 <ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{msg && <span className="text-sm text-emerald-600">{msg}</span>}
|
{msg && <span className="text-sm text-emerald-600">{msg}</span>}
|
||||||
{error && <span className="text-sm text-red-600">{error}</span>}
|
{error && <span className="text-sm text-red-600">{error}</span>}
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ function QualityBadge({ score }: { score: number }) {
|
|||||||
export default function ProductList({
|
export default function ProductList({
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string, ids: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [size] = useState(20);
|
const [size, setSize] = useState(20);
|
||||||
|
const [jump, setJump] = useState("");
|
||||||
const [rows, setRows] = useState<ProductRow[]>([]);
|
const [rows, setRows] = useState<ProductRow[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -164,7 +165,7 @@ export default function ProductList({
|
|||||||
onClose={() => setCreating(false)}
|
onClose={() => setCreating(false)}
|
||||||
onCreated={(id) => {
|
onCreated={(id) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
onOpen(id);
|
onOpen(id, [id]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -270,7 +271,7 @@ export default function ProductList({
|
|||||||
rows.map((r) => (
|
rows.map((r) => (
|
||||||
<tr
|
<tr
|
||||||
key={r.id}
|
key={r.id}
|
||||||
onClick={() => onOpen(r.id)}
|
onClick={() => onOpen(r.id, rows.map((x) => x.id))}
|
||||||
className={`cursor-pointer hover:bg-emerald-50/50 ${
|
className={`cursor-pointer hover:bg-emerald-50/50 ${
|
||||||
selected.has(r.id) ? "bg-emerald-50/60" : ""
|
selected.has(r.id) ? "bg-emerald-50/60" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -319,7 +320,25 @@ export default function ProductList({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2 text-sm text-gray-600">
|
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 text-sm text-gray-600">
|
||||||
|
<div className="mr-auto flex items-center gap-1">
|
||||||
|
<span>每页</span>
|
||||||
|
<select
|
||||||
|
value={size}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSize(Number(e.target.value));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="rounded border border-gray-300 px-2 py-1"
|
||||||
|
>
|
||||||
|
{[20, 50, 100].map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>条 · 共 {total} 条</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => setPage((p) => p - 1)}
|
onClick={() => setPage((p) => p - 1)}
|
||||||
@@ -337,6 +356,32 @@ export default function ProductList({
|
|||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = Number(jump);
|
||||||
|
if (Number.isFinite(n) && n >= 1) {
|
||||||
|
setPage(Math.min(Math.max(1, Math.trunc(n)), pages));
|
||||||
|
setJump("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>跳至</span>
|
||||||
|
<input
|
||||||
|
value={jump}
|
||||||
|
onChange={(e) => setJump(e.target.value.replace(/[^0-9]/g, ""))}
|
||||||
|
placeholder={String(page)}
|
||||||
|
className="w-14 rounded border border-gray-300 px-2 py-1 text-center"
|
||||||
|
aria-label="跳转页码"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded border border-gray-300 px-3 py-1 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
前往
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function StatsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
|
||||||
<BarChart3 className="h-5 w-5 text-emerald-600" /> 数据概览
|
<BarChart3 className="h-5 w-5 text-emerald-600" /> 数据概览
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function SubmissionsPage({ onPending }: { onPending?: (n: number)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="mx-auto max-w-5xl">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
{STATUS_TABS.map((t) => (
|
{STATUS_TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
@@ -193,7 +193,7 @@ function SubmissionView({ id, onBack }: { id: string; onBack: () => void }) {
|
|||||||
const nutri = Object.entries(p.nutriments || {});
|
const nutri = Object.entries(p.nutriments || {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
|
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
|
||||||
<ArrowLeft className="h-4 w-4" /> 返回投稿队列
|
<ArrowLeft className="h-4 w-4" /> 返回投稿队列
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Boxes, Search, PlusCircle, Code2 } from "lucide-react";
|
import { Boxes, Search, PlusCircle, Code2 } from "lucide-react";
|
||||||
import Home from "./components/Home";
|
import Home from "./components/Home";
|
||||||
import ProductView from "./components/ProductView";
|
import ProductView from "./components/ProductView";
|
||||||
import Contribute from "./components/Contribute";
|
import Contribute from "./components/Contribute";
|
||||||
import ApiDocs from "./components/ApiDocs";
|
import ApiDocs from "./components/ApiDocs";
|
||||||
|
import { api } from "./api";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| { name: "home" }
|
| { name: "home" }
|
||||||
@@ -13,6 +14,14 @@ type View =
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState<View>({ name: "home" });
|
const [view, setView] = useState<View>({ name: "home" });
|
||||||
|
const [qualified, setQualified] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.stats()
|
||||||
|
.then((s) => setQualified(s.qualified))
|
||||||
|
.catch(() => setQualified(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full flex flex-col">
|
<div className="min-h-full flex flex-col">
|
||||||
@@ -72,9 +81,17 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t bg-white">
|
<footer className="border-t bg-white">
|
||||||
<div className="max-w-5xl mx-auto px-4 py-4 text-xs text-gray-400 leading-relaxed">
|
<div className="max-w-5xl mx-auto px-4 py-4 text-xs text-gray-400 leading-relaxed text-center">
|
||||||
天工商品档案公共仓 是公益性「商品事实库」,仅收录客观商品信息(条码、品牌、品类、营养、官方建议零售价快照等),不含任何购买/交易功能。
|
{qualified != null && (
|
||||||
公众投稿须经人工审核后方可收纳。
|
<div className="mb-2 text-gray-500">
|
||||||
|
目前已收录
|
||||||
|
<span className="mx-1 font-semibold text-emerald-600">
|
||||||
|
{qualified.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
条合格商品档案
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
天工是一个公益性商品档案库。主要收录商品名称,条码,品类,配料等官方快照。不涉及任何交易行为。
|
||||||
<button onClick={() => setView({ name: "api" })} className="ml-1 text-emerald-600 hover:underline">
|
<button onClick={() => setView({ name: "api" })} className="ml-1 text-emerald-600 hover:underline">
|
||||||
API 调用说明
|
API 调用说明
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Search, PlusCircle, Code2 } from "lucide-react";
|
import { Search, PlusCircle, Code2 } from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { ProductSummary } from "../types";
|
import type { ProductSummary } from "../types";
|
||||||
@@ -13,31 +13,18 @@ export default function Home({
|
|||||||
onApi: () => void;
|
onApi: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [brand, setBrand] = useState("");
|
|
||||||
const [country, setCountry] = useState("");
|
|
||||||
const [items, setItems] = useState<ProductSummary[]>([]);
|
const [items, setItems] = useState<ProductSummary[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [qualified, setQualified] = useState<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api
|
|
||||||
.stats()
|
|
||||||
.then((s) => setQualified(s.qualified))
|
|
||||||
.catch(() => setQualified(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function run(e?: React.FormEvent) {
|
async function run(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await api.search(q.trim(), 1, 30, {
|
const res = await api.search(q.trim(), 1, 30);
|
||||||
brand: brand.trim() || undefined,
|
|
||||||
country: country.trim() || undefined,
|
|
||||||
});
|
|
||||||
setItems(res.items);
|
setItems(res.items);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
@@ -55,15 +42,6 @@ export default function Home({
|
|||||||
<p className="mt-2 text-gray-500">
|
<p className="mt-2 text-gray-500">
|
||||||
输入商品名称或条码,检索客观、可溯源的商品资料。人人可查,人人可贡献。
|
输入商品名称或条码,检索客观、可溯源的商品资料。人人可查,人人可贡献。
|
||||||
</p>
|
</p>
|
||||||
{qualified != null && (
|
|
||||||
<p className="mt-3 text-sm text-gray-500">
|
|
||||||
目前已收录
|
|
||||||
<span className="mx-1 font-semibold text-emerald-600">
|
|
||||||
{qualified.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
条合格商品档案
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<form onSubmit={run} className="mt-6 max-w-2xl mx-auto flex gap-2">
|
<form onSubmit={run} className="mt-6 max-w-2xl mx-auto flex gap-2">
|
||||||
<div className="flex-1 flex items-center gap-2 bg-white border rounded-lg px-3 shadow-sm focus-within:ring-2 focus-within:ring-emerald-400">
|
<div className="flex-1 flex items-center gap-2 bg-white border rounded-lg px-3 shadow-sm focus-within:ring-2 focus-within:ring-emerald-400">
|
||||||
<Search className="w-5 h-5 text-gray-400" />
|
<Search className="w-5 h-5 text-gray-400" />
|
||||||
@@ -83,20 +61,6 @@ export default function Home({
|
|||||||
{loading ? "检索中…" : "检索"}
|
{loading ? "检索中…" : "检索"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-3 max-w-2xl mx-auto flex flex-wrap items-center justify-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
value={brand}
|
|
||||||
onChange={(e) => setBrand(e.target.value)}
|
|
||||||
placeholder="按品牌筛选(如 Ferrero)"
|
|
||||||
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={country}
|
|
||||||
onChange={(e) => setCountry(e.target.value)}
|
|
||||||
placeholder="按产地筛选(如 China)"
|
|
||||||
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onApi}
|
onClick={onApi}
|
||||||
className="mt-4 inline-flex items-center gap-1.5 text-sm text-emerald-700 hover:underline"
|
className="mt-4 inline-flex items-center gap-1.5 text-sm text-emerald-700 hover:underline"
|
||||||
|
|||||||
Reference in New Issue
Block a user