feat: add manual video tagging
This commit is contained in:
+244
-106
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
import { ThumbsUp } from "lucide-react";
|
||||
import { Check, ThumbsUp, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { ASR_CONTENT_TAGS } from "@/lib/asr-tags";
|
||||
import HoverVideo from "./HoverVideo";
|
||||
|
||||
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
|
||||
@@ -18,6 +19,13 @@ type Props = {
|
||||
fetchUrl?: string;
|
||||
};
|
||||
|
||||
type TagUpdateResponse = {
|
||||
updated?: {
|
||||
aweme_id: string;
|
||||
content_tags: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function FeedMasonry({
|
||||
initialItems,
|
||||
initialCursor,
|
||||
@@ -27,6 +35,10 @@ export default function FeedMasonry({
|
||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ended, setEnded] = useState(!initialCursor);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchTag, setBatchTag] = useState<string>(ASR_CONTENT_TAGS[0]);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -168,115 +180,193 @@ export default function FeedMasonry({
|
||||
return () => io.disconnect();
|
||||
}, [fetchMore]);
|
||||
|
||||
const renderCard = useCallback(
|
||||
(item: FeedItem) => (
|
||||
<article
|
||||
key={item.aweme_id}
|
||||
className="mb-4 group relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<Link
|
||||
href={`/aweme/${item.aweme_id}`}
|
||||
target="_blank"
|
||||
className="block relative w-full"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
aspectRatio: getAspectRatio(item),
|
||||
}}
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<HoverVideo
|
||||
videoUrl={item.video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
fill
|
||||
src={item.cover_url || "/placeholder.svg"}
|
||||
alt={item.desc?.slice(0, 20) || "image"}
|
||||
unoptimized
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
{!!item.content_tags?.length && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
const toggleSelection = useCallback((awemeId: string) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(awemeId)) next.delete(awemeId);
|
||||
else next.add(awemeId);
|
||||
return next;
|
||||
});
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{item.author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${item.author.sec_uid}`}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||
{item.likes}{" "}
|
||||
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
),
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
const applyUpdatedTags = useCallback(
|
||||
(updates: TagUpdateResponse["updated"]) => {
|
||||
if (!updates?.length) return;
|
||||
const tagMap = new Map(
|
||||
updates.map((item) => [item.aweme_id, item.content_tags] as const),
|
||||
);
|
||||
setColumns((currentColumns) =>
|
||||
currentColumns.map((column) =>
|
||||
column.map((item) =>
|
||||
tagMap.has(item.aweme_id)
|
||||
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
|
||||
: item,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBatchAddTag = useCallback(async () => {
|
||||
const awemeIds = [...selectedIds];
|
||||
if (awemeIds.length === 0 || batchSaving) return;
|
||||
setBatchSaving(true);
|
||||
setBatchError(null);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
|
||||
});
|
||||
if (!response.ok) throw new Error("批量添加标签失败");
|
||||
const data: TagUpdateResponse = await response.json();
|
||||
applyUpdatedTags(data.updated);
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
setBatchError(
|
||||
error instanceof Error ? error.message : "批量添加标签失败",
|
||||
);
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
|
||||
|
||||
const renderCard = useCallback(
|
||||
(item: FeedItem) => {
|
||||
const selected = selectedIds.has(item.aweme_id);
|
||||
return (
|
||||
<article
|
||||
key={item.aweme_id}
|
||||
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
|
||||
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
|
||||
}`}
|
||||
>
|
||||
{item.type === "video" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSelection(item.aweme_id)}
|
||||
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
|
||||
selected
|
||||
? "border-blue-300 bg-blue-600"
|
||||
: "border-white/25 bg-black/35 hover:bg-black/55"
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? "取消选择视频" : "选择视频"}
|
||||
>
|
||||
{selected && <Check size={17} />}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/aweme/${item.aweme_id}`}
|
||||
target="_blank"
|
||||
className="block relative w-full"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
aspectRatio: getAspectRatio(item),
|
||||
}}
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<HoverVideo
|
||||
videoUrl={item.video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
fill
|
||||
src={item.cover_url || "/placeholder.svg"}
|
||||
alt={item.desc?.slice(0, 20) || "image"}
|
||||
unoptimized
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
{!!item.content_tags?.length && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{item.author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${item.author.sec_uid}`}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||
{item.likes}{" "}
|
||||
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
[selectedIds, toggleSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||
@@ -299,6 +389,54 @@ export default function FeedMasonry({
|
||||
>
|
||||
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||
</div>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 w-[min(92vw,36rem)] -translate-x-1/2 rounded-2xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
已选择 {selectedIds.size} 个视频
|
||||
</span>
|
||||
{batchError && (
|
||||
<span className="text-xs text-rose-300">{batchError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={batchTag}
|
||||
onChange={(event) => setBatchTag(event.target.value)}
|
||||
className="h-9 rounded-full border border-white/15 bg-white/10 px-3 text-sm text-white outline-none"
|
||||
aria-label="选择要添加的标签"
|
||||
>
|
||||
{ASR_CONTENT_TAGS.map((tag) => (
|
||||
<option
|
||||
key={tag}
|
||||
value={tag}
|
||||
className="bg-zinc-900 text-white"
|
||||
>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchAddTag}
|
||||
disabled={batchSaving}
|
||||
className="h-9 rounded-full bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||
>
|
||||
{batchSaving ? "添加中..." : "添加标签"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||
aria-label="清空选择"
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user