auto-save 2026-05-14 13:49 (~4)
This commit is contained in:
@@ -64,7 +64,9 @@ const PRODUCT_FUSION_WEARING_PROMPT = [
|
||||
|
||||
const PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT = [
|
||||
"Product identity is strict:",
|
||||
"The four SKG product reference images are real product photographs, not concept art and not style inspiration. Treat them as the immutable physical object to insert into the shot.",
|
||||
"The four SKG product reference images are the single source of truth for the object. Preserve the same white U-shaped body, rounded arms, inner massage pads/nodes, side buttons, seams, glossy plastic material, thickness, proportions, and viewing angles.",
|
||||
"Use visual compositing behavior: place the real product object onto the character externally, then match lighting, shadow, scale, and perspective around it. Do not redraw a new product from memory.",
|
||||
"Do not redesign, stylize, simplify, melt, inflate, shrink, recolor, add logos/text/screens/wires/extra parts, or turn it into a generic neckband/headphone/medical brace.",
|
||||
"If the product and character conflict, prioritize preserving the product shape and place it externally on the neck rather than merging it into the character.",
|
||||
].join("\n")
|
||||
|
||||
@@ -118,12 +118,25 @@ const DESKTOP_PRODUCT_ANGLE_IDS = [
|
||||
]
|
||||
const DEFAULT_CHARACTER_ID = "character-01"
|
||||
const DEFAULT_CHARACTER_NAME = "运动阳光男"
|
||||
type FusionPreviewAnchor = { kind: "product" | "video"; id: string; x: number; y: number }
|
||||
type FusionUploadTarget = {
|
||||
shotIndex: number
|
||||
slot: "first_image" | "last_image"
|
||||
}
|
||||
type FusionFrameRole = "first_image" | "last_image"
|
||||
const FUSION_PROMPT_MARKER_PREFIX = "产品融合镜头ID:"
|
||||
|
||||
function lightboxPreviewAnchor(root: HTMLDivElement | null, target: HTMLElement) {
|
||||
if (!root) return { x: 320, y: 0 }
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
if (rootRect.width <= 0 || rootRect.height <= 0) return { x: root.clientWidth / 2, y: 0 }
|
||||
return {
|
||||
x: targetRect.left + targetRect.width / 2 - rootRect.left,
|
||||
y: targetRect.top - rootRect.top,
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_PRODUCT_FUSION_DESCRIPTION_PRESETS = [
|
||||
"清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑。",
|
||||
"现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适。",
|
||||
@@ -208,12 +221,12 @@ const FUSION_STAGE_PLANS = [
|
||||
},
|
||||
{
|
||||
refs: "侧面 + 背部特写 + 产品侧面/背面",
|
||||
product: "SKG 准确落到脖子、后颈、颈肩交界处,双手扶两端微调贴合",
|
||||
result: "重点解决佩戴位置、产品尺寸、透视和后颈贴合真实性",
|
||||
product: "SKG 作为外置刚性实物准确落到后颈外侧,两端沿颈侧向前,双手扶两端微调接触角度",
|
||||
result: "重点解决佩戴位置、产品真实尺寸、透视、遮挡关系和不穿模",
|
||||
},
|
||||
{
|
||||
refs: "半身近景 + 侧面",
|
||||
product: "产品已经稳定佩戴,人物轻按控制区或保持佩戴状态",
|
||||
product: "产品已经稳定外置佩戴,人物轻按控制区或保持佩戴状态",
|
||||
result: "表现闭眼、肩部下沉、呼吸放慢、舒适享受,但不做医疗疗效承诺",
|
||||
},
|
||||
{
|
||||
@@ -223,7 +236,7 @@ const FUSION_STAGE_PLANS = [
|
||||
},
|
||||
{
|
||||
refs: "半身近景 + 背部特写 + 产品主视角",
|
||||
product: "收尾停在肩颈和产品清楚可辨的位置,手不遮挡产品关键轮廓",
|
||||
product: "收尾停在肩颈和真实产品清楚可辨的位置,手不遮挡产品关键轮廓",
|
||||
result: "形成广告记忆点:角色舒适、产品清楚、画面干净高级",
|
||||
},
|
||||
]
|
||||
@@ -313,7 +326,8 @@ const fusionDescriptionForCharacter = (characterId: string, presetIndex: number)
|
||||
`自主图像编排:本镜头主要参考角色图【${stagePlan.refs}】,场景生成【${scene}】。`,
|
||||
`产品调度:${stagePlan.product}。`,
|
||||
`镜头目标:${stagePlan.result}。`,
|
||||
`产品使用:${profile.usage};产品只能在脖子、后颈、颈肩交界处使用,尺寸必须符合真实颈部按摩仪比例。`,
|
||||
`真实产品合成:四张 SKG 产品图是真实实物真源,只能作为外置刚性设备佩戴在透明身体外侧;后颈外侧承托,两端沿左右颈侧向前,不能穿进透明皮肤、骨架、喉咙或肩部。`,
|
||||
`产品使用:${profile.usage};产品只能在脖子、后颈、颈肩交界处外置使用,尺寸必须符合真实颈部按摩仪比例。`,
|
||||
`享受状态:${profile.enjoyment};不要医疗治疗承诺,不要恐怖解剖感。`,
|
||||
].join("\n")
|
||||
}
|
||||
@@ -390,7 +404,9 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
const [fusionSaving, setFusionSaving] = useState(false)
|
||||
const [fusionPresetPage, setFusionPresetPage] = useState(0)
|
||||
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
||||
const [productLibrary, setProductLibrary] = useState<ProductLibraryItem[]>([])
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState(DEFAULT_CHARACTER_ID)
|
||||
const [fusionHoverPreview, setFusionHoverPreview] = useState<FusionPreviewAnchor | null>(null)
|
||||
const [editingElement, setEditingElement] = useState<{
|
||||
frameIndex: number
|
||||
id: string
|
||||
@@ -406,6 +422,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
const [draftRegion, setDraftRegion] = useState<Region | null>(null) // 当前正在拖的
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const imgWrapRef = useRef<HTMLDivElement>(null)
|
||||
const fusionPreviewRootRef = useRef<HTMLDivElement>(null)
|
||||
const loadedFusionKey = useRef("")
|
||||
const activeIndexRef = useRef<number | null>(activeIndex)
|
||||
useEffect(() => setMounted(true), [])
|
||||
@@ -423,6 +440,18 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
listProductLibrary()
|
||||
.then((items) => {
|
||||
if (!cancelled) setProductLibrary(items)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) toast.error("产品图读取失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex === null) {
|
||||
loadedFusionKey.current = ""
|
||||
@@ -542,6 +571,9 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
].filter(Boolean).join("\n")
|
||||
const fusionReadyCount = fusionShots.filter((shot) => shot.action_text?.trim()).length
|
||||
const selectedCharacter = characterLibrary.find((item) => item.id === selectedCharacterId) ?? characterLibrary[0]
|
||||
const fixedProductItems = DESKTOP_PRODUCT_ANGLE_IDS
|
||||
.map((id) => productLibrary.find((item) => item.id === id))
|
||||
.filter((item): item is ProductLibraryItem => Boolean(item))
|
||||
|
||||
const persistFusionShots = async (nextShots: ProductFusionShot[]) => {
|
||||
setFusionSaving(true)
|
||||
@@ -1193,7 +1225,46 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
</div>
|
||||
</section>
|
||||
) : isProductTab ? (
|
||||
<section className="rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5">
|
||||
<section ref={fusionPreviewRootRef} className="relative overflow-visible rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5">
|
||||
{(() => {
|
||||
if (!fusionHoverPreview) return null
|
||||
if (fusionHoverPreview.kind === "product") {
|
||||
const item = fixedProductItems.find((product) => product.id === fusionHoverPreview.id)
|
||||
if (!item) return null
|
||||
return (
|
||||
<HoverPreview
|
||||
imgSrc={apiAssetUrl(item.url)}
|
||||
aspect="1/1"
|
||||
label={`真实产品图 ${item.image_index}`}
|
||||
caption={`${item.width}×${item.height}`}
|
||||
borderClass="border-amber-300/70"
|
||||
visible
|
||||
anchorX={fusionHoverPreview.x}
|
||||
anchorY={fusionHoverPreview.y}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const item = generatedVideos.find((video) => video.id === fusionHoverPreview.id)
|
||||
if (!item) return null
|
||||
const videoSrc = apiAssetUrl(item.url)
|
||||
const posterSrc = apiAssetUrl(item.poster_url)
|
||||
const ready = item.status === "completed" && !!videoSrc
|
||||
if (!ready && !posterSrc) return null
|
||||
return (
|
||||
<HoverPreview
|
||||
videoSrc={ready ? videoSrc : undefined}
|
||||
imgSrc={!ready ? posterSrc : undefined}
|
||||
poster={posterSrc}
|
||||
aspect="9/16"
|
||||
label={`产品融合 · 分镜 ${item.frame_idx + 1}`}
|
||||
caption={item.status === "completed" ? `${item.duration.toFixed(0)}s` : `${item.status} · ${item.progress ?? 0}%`}
|
||||
borderClass={ready ? "border-emerald-300/70" : "border-amber-300/70"}
|
||||
visible
|
||||
anchorX={fusionHoverPreview.x}
|
||||
anchorY={fusionHoverPreview.y}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[12px] font-semibold text-white">产品融合镜头组</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -1221,86 +1292,141 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 grid grid-cols-[minmax(220px,300px)_1fr] gap-2 rounded-md border border-white/10 bg-black/25 p-2">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[9px] text-white/38">内置角色</div>
|
||||
<select
|
||||
value={selectedCharacterId}
|
||||
onChange={(e) => selectFusionCharacter(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white/80 outline-none focus:border-amber-300/45"
|
||||
>
|
||||
{(characterLibrary.length ? characterLibrary : [{ id: DEFAULT_CHARACTER_ID, name: DEFAULT_CHARACTER_NAME } as CharacterLibraryItem]).map((character) => (
|
||||
<option key={character.id} value={character.id}>{character.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mb-2 grid grid-cols-[minmax(260px,1fr)_minmax(390px,520px)] gap-2 rounded-md border border-white/10 bg-black/25 p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-white/38">角色参考</span>
|
||||
<span className="text-[8.5px] text-white/30">产品固定 4 图 · 无首尾帧</span>
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[9px] text-white/38">内置角色</div>
|
||||
<select
|
||||
value={selectedCharacterId}
|
||||
onChange={(e) => selectFusionCharacter(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white/80 outline-none focus:border-amber-300/45"
|
||||
>
|
||||
{(characterLibrary.length ? characterLibrary : [{ id: DEFAULT_CHARACTER_ID, name: DEFAULT_CHARACTER_NAME } as CharacterLibraryItem]).map((character) => (
|
||||
<option key={character.id} value={character.id}>{character.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-white/38">角色参考</span>
|
||||
<span className="text-[8.5px] text-white/30">人物身份参考</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 overflow-hidden">
|
||||
{(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
|
||||
<div key={image.id} className="h-12 w-10 overflow-hidden rounded border border-white/10 bg-black/35">
|
||||
<img src={characterLibraryImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" draggable={false} />
|
||||
</div>
|
||||
))}
|
||||
{!selectedCharacter?.images?.length && (
|
||||
<div className="flex h-12 items-center rounded border border-dashed border-white/10 px-2 text-[9.5px] text-white/32">
|
||||
角色库加载中
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 overflow-hidden">
|
||||
{(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
|
||||
<div key={image.id} className="h-12 w-10 overflow-hidden rounded border border-white/10 bg-black/35">
|
||||
<img src={characterLibraryImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" draggable={false} />
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border border-amber-300/25 bg-amber-300/[0.08] p-2">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold text-amber-50">真实产品真源 · 固定 4 图</span>
|
||||
<span className="text-[8.5px] text-amber-100/45">生成时按实物合成,不自由重绘</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{fixedProductItems.length > 0 ? fixedProductItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative overflow-visible rounded-md border border-white/15 bg-white shadow-[0_8px_24px_rgba(0,0,0,0.22)]"
|
||||
onMouseEnter={(e) => setFusionHoverPreview({ kind: "product", id: item.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setFusionHoverPreview(null)}
|
||||
title={`真实产品角度 ${index + 1}`}
|
||||
>
|
||||
<div className="relative aspect-square">
|
||||
<img src={apiAssetUrl(item.url)} alt={`真实产品角度 ${index + 1}`} className="absolute inset-0 h-full w-full object-contain p-1.5" draggable={false} />
|
||||
<div className="absolute left-1 top-1 rounded bg-black/75 px-1 py-0.5 text-[8px] font-mono text-white">
|
||||
P{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-black/8 bg-black/5 px-1 py-0.5 text-center text-[8px] font-mono text-black/55">
|
||||
{item.width}×{item.height}
|
||||
</div>
|
||||
</div>
|
||||
)) : DESKTOP_PRODUCT_ANGLE_IDS.map((id, index) => (
|
||||
<div key={id} className="flex aspect-square items-center justify-center rounded-md border border-dashed border-white/15 bg-black/25 text-[9px] text-white/30">
|
||||
P{index + 1}
|
||||
</div>
|
||||
))}
|
||||
{!selectedCharacter?.images?.length && (
|
||||
<div className="flex h-12 items-center rounded border border-dashed border-white/10 px-2 text-[9.5px] text-white/32">
|
||||
角色库加载中
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5 text-[10px] leading-relaxed text-white/50">
|
||||
角色和产品已内置;每行只写场景、产品如何在脖子/后颈使用,以及人物舒适享受的状态。
|
||||
四张产品图是真实产品真源;生成时按外置刚性实物佩戴到后颈,不把产品融进透明身体。每行可反复生成,结果会继续往后追加。
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{fusionShots.map((shot, i) => {
|
||||
const active = i === activeFusionShot
|
||||
const shotMarker = `${FUSION_PROMPT_MARKER_PREFIX}${shot.id}`
|
||||
const shotVideos = generatedVideos.filter((video) => video.frame_idx === f.index && video.prompt.includes(shotMarker))
|
||||
const latestShotVideo = shotVideos[0]
|
||||
const latestVideoUrl = latestShotVideo?.url ? apiAssetUrl(latestShotVideo.url) : ""
|
||||
const shotVideos = generatedVideos
|
||||
.filter((video) => video.frame_idx === f.index && video.prompt.includes(shotMarker))
|
||||
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
||||
const ready = !!shot.action_text?.trim()
|
||||
const busy = fusionGenerating === i || fusionGenerating === "all"
|
||||
const lensStageLabel = PRODUCT_FUSION_LENS_STAGES[i] ?? `镜头 ${i + 1}`
|
||||
const resultPanel = latestShotVideo ? (
|
||||
<div className="overflow-hidden rounded-md border border-white/10 bg-black/30">
|
||||
<div className="relative aspect-video bg-black">
|
||||
{latestShotVideo.status === "completed" && latestVideoUrl ? (
|
||||
<video src={latestVideoUrl} controls muted playsInline preload="metadata" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 px-2 text-center text-[9.5px] text-white/42">
|
||||
{latestShotVideo.status === "failed" ? (
|
||||
<>
|
||||
<X className="h-4 w-4 text-rose-300" />
|
||||
<span className="text-rose-100/75">生成失败</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-200/80" />
|
||||
<span>{latestShotVideo.status === "queued" ? "排队中" : "生成中"} · {latestShotVideo.progress ?? 0}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
const resultPanel = shotVideos.length > 0 ? (
|
||||
<div className="rounded-md border border-white/10 bg-black/30 p-1.5">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[8.5px] text-white/42">历史结果</span>
|
||||
<span className="rounded bg-white/8 px-1 py-0.5 text-[8px] font-mono text-white/45">{shotVideos.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 border-t border-white/10 px-1.5 py-1">
|
||||
<span className="truncate text-[8.5px] text-white/42">
|
||||
{latestShotVideo.status === "completed" ? "已完成" : latestShotVideo.status}
|
||||
</span>
|
||||
{onDeleteVideo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteVideo(latestShotVideo.id)}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded bg-white/10 text-white/55 hover:bg-rose-500/70 hover:text-white"
|
||||
title="删除此视频"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex max-w-full gap-1.5 overflow-x-auto pb-1">
|
||||
{shotVideos.map((video, videoIndex) => {
|
||||
const videoUrl = video.url ? apiAssetUrl(video.url) : ""
|
||||
const posterUrl = video.poster_url ? apiAssetUrl(video.poster_url) : ""
|
||||
const completed = video.status === "completed" && !!videoUrl
|
||||
return (
|
||||
<div
|
||||
key={video.id}
|
||||
className={`group relative h-[124px] w-[74px] shrink-0 overflow-hidden rounded-md border bg-black ${
|
||||
completed ? "border-emerald-300/55" : video.status === "failed" ? "border-rose-300/65" : "border-amber-300/55"
|
||||
}`}
|
||||
onMouseEnter={(e) => setFusionHoverPreview({ kind: "video", id: video.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setFusionHoverPreview(null)}
|
||||
title="鼠标停留放大预览"
|
||||
>
|
||||
{completed ? (
|
||||
<video src={videoUrl} muted loop playsInline preload="metadata" className="h-full w-full object-cover" />
|
||||
) : posterUrl ? (
|
||||
<img src={posterUrl} alt="" className="h-full w-full object-cover opacity-75" draggable={false} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-black/40">
|
||||
{video.status === "failed" ? <X className="h-4 w-4 text-rose-200" /> : <Loader2 className="h-4 w-4 animate-spin text-amber-200" />}
|
||||
</div>
|
||||
)}
|
||||
{!completed && posterUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
{video.status === "failed" ? <X className="h-4 w-4 text-rose-200" /> : <Loader2 className="h-4 w-4 animate-spin text-amber-200" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/92 to-transparent px-1 py-1">
|
||||
<div className="truncate text-[8.5px] font-semibold text-white">#{shotVideos.length - videoIndex}</div>
|
||||
<div className="truncate text-[7.5px] font-mono text-white/65">
|
||||
{completed ? `${video.duration.toFixed(0)}s` : video.status === "failed" ? "failed" : `${video.progress ?? 0}%`}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteVideo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteVideo(video.id)
|
||||
}}
|
||||
className="absolute right-1 top-1 inline-flex h-5 w-5 items-center justify-center rounded-full bg-black/65 text-white/65 opacity-0 transition hover:bg-rose-500 hover:text-white group-hover:opacity-100"
|
||||
title="删除此视频"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1317,7 +1443,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
: "border-white/10 bg-black/20 hover:border-amber-300/35"
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-[34px_minmax(360px,1fr)_78px_190px] items-start gap-2">
|
||||
<div className="grid grid-cols-[34px_minmax(340px,1fr)_78px_minmax(240px,300px)] items-start gap-2">
|
||||
<div className="flex flex-col items-center gap-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user