feat(core/frontend): P1 pixel restoration (settings/messages/wizard/product-create faithful; ai-tools draft)
- settings.tsx + settings-page.css: restore to settings.html (left-nav + sections), real user/team data - messages.tsx + messages-page.css: rich inbox restore (filters/detail/props grid) on real notifications - projects.tsx ProjectWizardPage + project-wizard-page.css: restore to projects-new.html - products.tsx ProductCreateUploadPage + product-create-page.css: restore to product-create-v2 baseline - ai-tools.tsx (AssetFactory/ImageWorkbench) + ai-tools-page.css: DRAFT unified studio shell; deviates from per-page baselines (image-optimize should be chat-stream; model-photo product+person picker) -> pending rework alongside P3 standalone image-gen decision - shot-p1.mjs: playwright visual-parity capture (react vs exact baseline; uses 127.0.0.1 not localhost) - verified: tsc --noEmit clean; screenshots confirm settings/wizard/product-create/messages faithful Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
25bf3293df
commit
78fd7ee13d
@@ -1,9 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||||
import "../project-wizard-page.css";
|
||||
|
||||
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
|
||||
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
|
||||
const WIZ_DURATIONS = [
|
||||
{ id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "黄金完播" },
|
||||
{ id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "完播率最佳" },
|
||||
{ id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点详解" },
|
||||
{ id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化" }
|
||||
];
|
||||
const WIZ_STYLES = [
|
||||
{ id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用" },
|
||||
{ id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", tag: "" },
|
||||
{ id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", tag: "" }
|
||||
];
|
||||
const WIZ_PERSONAS = [
|
||||
{ id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", dur: "0-15", style: "pain" },
|
||||
{ id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", dur: "0-15", style: "pain" },
|
||||
{ id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", dur: "0-30", style: "pain" },
|
||||
{ id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", dur: "0-30", style: "review" },
|
||||
{ id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", dur: "0-30", style: "pain" },
|
||||
{ id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", dur: "0-10", style: "compare" }
|
||||
];
|
||||
|
||||
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
||||
|
||||
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
products: Product[];
|
||||
@@ -12,26 +36,311 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
}) {
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
const [name, setName] = useState(() => `${products[0]?.title || "商品"} · 短视频 · ${new Date().toLocaleDateString("zh-CN")}`);
|
||||
const [name, setName] = useState(() => `${(products[0]?.title || "商品").split(" ")[0]} · 痛点种草 · v1`);
|
||||
|
||||
// Step 1 · 商品选择器本地交互态
|
||||
const [pickSearch, setPickSearch] = useState("");
|
||||
const [pickCat, setPickCat] = useState("全部");
|
||||
const [catOpen, setCatOpen] = useState(false);
|
||||
const [pickView, setPickView] = useState<"grid" | "list">("grid");
|
||||
const [pickPage, setPickPage] = useState(1);
|
||||
|
||||
// Step 2 · 配置(视觉选择,详见 Stage 1)
|
||||
const [duration, setDuration] = useState<string | null>("0-15");
|
||||
const [scriptStyle, setScriptStyle] = useState<string | null>("pain");
|
||||
const [persona, setPersona] = useState<string | null>(null);
|
||||
const [recoDismissed, setRecoDismissed] = useState(false);
|
||||
const [points, setPoints] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId && products[0]) setProductId(products[0].id);
|
||||
}, [productId, products]);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (product) void onCreate({ name: name || `${product.title} · 短视频`, product: product.id });
|
||||
// 分类清单
|
||||
const cats = useMemo(
|
||||
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
|
||||
[products]
|
||||
);
|
||||
|
||||
// 筛选 + 排序后的商品
|
||||
const filtered = useMemo(() => {
|
||||
const q = pickSearch.trim().toLowerCase();
|
||||
return products.filter((p) => {
|
||||
const cat = p.category || "未分类";
|
||||
if (pickCat !== "全部" && cat !== pickCat) return false;
|
||||
if (q) {
|
||||
const blob = `${p.title} ${cat} ${(p.selling_points || []).map((s) => s.title).join(" ")}`.toLowerCase();
|
||||
if (!blob.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [products, pickSearch, pickCat]);
|
||||
|
||||
const hasFilter = !!pickSearch || pickCat !== "全部";
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / WIZ_PAGE_SIZE));
|
||||
const cur = Math.min(pickPage, totalPages);
|
||||
const pageList = filtered.slice((cur - 1) * WIZ_PAGE_SIZE, cur * WIZ_PAGE_SIZE);
|
||||
|
||||
function selectProduct(id: string) {
|
||||
setProductId(id);
|
||||
const p = products.find((item) => item.id === id);
|
||||
if (p) {
|
||||
const seed: Record<string, boolean> = {};
|
||||
(p.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
|
||||
setPoints(seed);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPickFilters() {
|
||||
setPickSearch("");
|
||||
setPickCat("全部");
|
||||
setPickPage(1);
|
||||
}
|
||||
|
||||
function applyPreset() {
|
||||
const p = WIZ_PERSONAS.find((item) => item.id === persona);
|
||||
if (!p) return;
|
||||
setDuration(p.dur);
|
||||
setScriptStyle(p.style);
|
||||
setRecoDismissed(false);
|
||||
}
|
||||
|
||||
const personaObj = WIZ_PERSONAS.find((p) => p.id === persona);
|
||||
const durObj = WIZ_DURATIONS.find((d) => d.id === duration);
|
||||
const styleObj = WIZ_STYLES.find((s) => s.id === scriptStyle);
|
||||
const showReco =
|
||||
!!personaObj && !!duration && !!scriptStyle && !recoDismissed &&
|
||||
(personaObj.dur !== duration || personaObj.style !== scriptStyle);
|
||||
const recoDur = personaObj && WIZ_DURATIONS.find((d) => d.id === personaObj.dur);
|
||||
const recoStyle = personaObj && WIZ_STYLES.find((s) => s.id === personaObj.style);
|
||||
|
||||
const product1Done = !!productId;
|
||||
const config2Done = !!duration && !!styleObj && name.trim().length >= 2;
|
||||
const canStart = product1Done && config2Done;
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canStart || !product) return;
|
||||
void onCreate({ name: name.trim() || `${product.title} · 短视频`, product: product.id });
|
||||
}
|
||||
|
||||
const productCover = (p: Product): CSSProperties | undefined => {
|
||||
const file = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || p.images?.[0]?.asset;
|
||||
return file ? ({ ["--mock-media-url"]: `url(${file})` } as CSSProperties) : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head"><div><h1>新建视频项目</h1><div className="sub"><span className="mono">// 选择商品 → 创建项目 → 进入 Stage 1 脚本</span></div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}>退出</button></div></div>
|
||||
<section className="project-wizard-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>新建项目</h1>
|
||||
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="wizard" onSubmit={submit}>
|
||||
<nav className="steps" aria-label="新建项目步骤"><div className="step active"><div className="num">1</div><div><div className="label">选择商品</div><div className="desc">{product?.title || "未选择"}</div></div></div><div className="step"><div className="num">2</div><div><div className="label">项目名称</div><div className="desc">自动生成可修改</div></div></div><div className="step"><div className="num">3</div><div><div className="label">进入脚本</div><div className="desc">Stage 1 配置脚本</div></div></div></nav>
|
||||
<div><section className="wiz-pane active"><div className="wiz-step-h"><h2>为哪个商品创建视频?</h2><p>按定稿,创建页只绑定商品和项目名称,脚本风格、卖点重点放到 Stage 1。</p></div><div className="product-select-grid">{products.map((item) => <button className={`product-pick ${productId === item.id ? "selected" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title}</span></div><strong>{item.title}</strong><span>{item.category || "未分类"} · {item.selling_points.length} 个卖点</span></button>)}</div>{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}<div className="field" style={{ marginTop: 18 }}><label className="field-label">项目名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="wiz-foot"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回</button><div className="hstack"><span className="muted-2 mono">// 下一步:流水线 Stage 1</span><button className="btn btn-primary btn-lg" type="submit" disabled={!product}><ArrowRight size={13} />创建并进入脚本</button></div></div></section></div>
|
||||
<aside className="wiz-preview"><div className="pv-h"><span>实时预估</span><span className="live">LIVE</span></div><div className="pv-title">{name || product?.title || "未命名项目"}</div><div className="pv-metrics"><div className="pv-metric"><div className="l">片段</div><div className="v">4<small>段</small></div></div><div className="pv-metric accent"><div className="l">总时长</div><div className="v">60<small>s</small></div></div><div className="pv-metric"><div className="l">阶段</div><div className="v">5</div></div></div></aside>
|
||||
{/* ── 左侧步骤轨 ── */}
|
||||
<nav className="steps" aria-label="新建项目步骤">
|
||||
<div className={`step ${product1Done ? "done" : "active"}`}>
|
||||
<div className="num">{product1Done ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
) : "1"}</div>
|
||||
<div>
|
||||
<div className="label">选择商品</div>
|
||||
<div className="desc">{product?.title || "未选择"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`step ${config2Done ? "done" : product1Done ? "active" : ""}`}>
|
||||
<div className="num">{config2Done ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
) : "2"}</div>
|
||||
<div>
|
||||
<div className="label">项目配置</div>
|
||||
<div className="desc">{durObj && styleObj ? `${durObj.label} · ${styleObj.name}` : "时长 · 风格 · 人物"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── 主体 ── */}
|
||||
<div className="wiz-body">
|
||||
{/* Step 1 · 商品选择 */}
|
||||
<section className="step-pane-wrap">
|
||||
<div className="wiz-pane">
|
||||
<div className="wiz-step-h">
|
||||
<h2>第 1 步 · 选择商品</h2>
|
||||
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
|
||||
</div>
|
||||
|
||||
<div className="pp-toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input type="text" placeholder="搜索商品名称、标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} />
|
||||
</div>
|
||||
<div className={`pp-chip-wrap${catOpen ? " open" : ""}`}>
|
||||
<button className={`pp-chip${pickCat !== "全部" ? " active" : ""}`} type="button" onClick={() => setCatOpen((open) => !open)}>
|
||||
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="pp-menu">
|
||||
{cats.map((c) => (
|
||||
<div className={`mi${pickCat === c ? " selected" : ""}`} key={c} onClick={() => { setPickCat(c); setPickPage(1); setCatOpen(false); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6"><polyline points="3 8 7 12 13 4" /></svg>
|
||||
<span>{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<button className="pp-clear" type="button" onClick={clearPickFilters}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
清空筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
|
||||
|
||||
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
|
||||
<div className="pp-create-card" onClick={onBack}>
|
||||
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
|
||||
<div className="pc-t">创建新商品</div>
|
||||
<div className="pc-d">// 在此添加一个新商品</div>
|
||||
</div>
|
||||
{total === 0 ? (
|
||||
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
|
||||
) : (
|
||||
pageList.map((p) => (
|
||||
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
|
||||
<div className={`placeholder product-thumb${productCover(p) ? " has-mock-media" : ""}`} style={productCover(p)}><span className="ph-frame">{p.title} · 1200×800</span></div>
|
||||
<div className="product-body">
|
||||
<div className="product-name">{p.title}</div>
|
||||
<div className="product-cat">{p.category || "未分类"}</div>
|
||||
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > WIZ_PAGE_SIZE && (
|
||||
<div className="pp-pager">
|
||||
<span className="total">共 {total} 条</span>
|
||||
<div className="pages">
|
||||
<button type="button" disabled={cur === 1} onClick={() => setPickPage(cur - 1)}>‹</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button type="button" key={n} className={n === cur ? "active" : ""} onClick={() => setPickPage(n)}>{n}</button>
|
||||
))}
|
||||
<button type="button" disabled={cur === totalPages} onClick={() => setPickPage(cur + 1)}>›</button>
|
||||
</div>
|
||||
<span className="page-size">每页 {WIZ_PAGE_SIZE} 条</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pp-bottom-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
|
||||
<span>找不到想要的商品?可<a onClick={onBack}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 2 · 项目配置 */}
|
||||
<section className="step-pane-wrap">
|
||||
<div className="wiz-pane">
|
||||
<div className="wiz-step-h">
|
||||
<h2>第 2 步 · 项目配置</h2>
|
||||
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
|
||||
</div>
|
||||
|
||||
<div className="config-row">
|
||||
<div className="field">
|
||||
<label className="field-label">项目名称<span className="req">*</span></label>
|
||||
<input className="input" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">视频时长<span className="req">*</span></label>
|
||||
<select className="select duration-select" value={duration || ""} onChange={(event) => setDuration(event.target.value || null)}>
|
||||
<option value="" disabled>选择时长</option>
|
||||
{WIZ_DURATIONS.map((d) => (
|
||||
<option value={d.id} key={d.id}>{d.label} · {d.shots[0]}-{d.shots[1]} 镜</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">脚本风格</label>
|
||||
<div className="opt-row cols-4">
|
||||
{WIZ_STYLES.map((s) => (
|
||||
<div className={`opt-card${scriptStyle === s.id ? " selected" : ""}`} key={s.id} onClick={() => setScriptStyle(s.id)}>
|
||||
<h4>{s.name}</h4>
|
||||
<div className="note">{s.note}</div>
|
||||
{s.tag && <span className="badge">[ {s.tag} ]</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">人物设定</label>
|
||||
<div className="opt-row cols-6">
|
||||
{WIZ_PERSONAS.map((p) => (
|
||||
<div className={`opt-card${persona === p.id ? " selected" : ""}`} key={p.id} onClick={() => { setPersona(p.id); setRecoDismissed(false); }}>
|
||||
<h4>{p.name}</h4>
|
||||
<div className="sub">{p.sub}</div>
|
||||
<div className="metric"><span className="val">{p.metric}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showReco && recoDur && recoStyle && durObj && styleObj && (
|
||||
<div className="reco-bubble">
|
||||
<span className="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 16v-4M12 8h.01" /></svg>
|
||||
</span>
|
||||
<div className="txt">
|
||||
<span>抖音同人设 TOP 视频更常用 <strong>{recoDur.label}</strong> + <strong>{recoStyle.name}</strong></span>
|
||||
<span className="meta">当前 {durObj.label} · {styleObj.name} → 推荐换为同人设最优组合</span>
|
||||
</div>
|
||||
<button className="btn-apply" type="button" onClick={applyPreset}>一键套用</button>
|
||||
<button className="dismiss" type="button" onClick={() => setRecoDismissed(true)} aria-label="忽略">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(points).length > 0 && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
|
||||
<div className="theme-pill-row">
|
||||
{Object.entries(points).map(([k, v]) => (
|
||||
<button className={`theme-pill${v ? " active" : ""}`} type="button" key={k} aria-pressed={v} onClick={() => setPoints((prev) => ({ ...prev, [k]: !prev[k] }))}>
|
||||
{v && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>}
|
||||
<span>{k}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 底部「开始」CTA ── */}
|
||||
<div className="wiz-start-bar">
|
||||
<button className={`btn-start${canStart ? "" : " disabled"}`} type="submit" disabled={!canStart}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
|
||||
<span>开始</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user