diff --git a/app/api/playlist/route.ts b/app/api/playlist/route.ts new file mode 100644 index 0000000..f2441fd --- /dev/null +++ b/app/api/playlist/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { json } from "@/lib/json"; +import { parsePlaybackContext } from "@/lib/playback-context"; +import { contextItems } from "@/lib/playback-context.server"; + +// 按播放上下文的顺序列出作品,供播放页侧栏的网格使用。 +// 参数与 /aweme/ 上的上下文参数相同:?ctx=search&q=…、?ctx=hashtag&hashtag=…、?ctx=author&author=… 等。 +export async function GET(req: Request) { + const ctx = parsePlaybackContext(new URL(req.url).searchParams); + if (!ctx) { + return NextResponse.json({ error: "Invalid context" }, { status: 400 }); + } + try { + return json(await contextItems(ctx)); + } catch (err) { + console.error("Playlist error:", err); + return NextResponse.json({ error: "Playlist failed" }, { status: 500 }); + } +} + +export const dynamic = "force-dynamic"; diff --git a/app/api/search/route.ts b/app/api/search/route.ts index be898cb..5a8b0c1 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -2,26 +2,10 @@ import { NextResponse } from "next/server"; import { json } from "@/lib/json"; import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; -import { - buildSnippet, - highlightAll, - parseSearchQuery, - termToPgPattern, -} from "@/lib/search"; +import { buildSnippet, highlightAll, parseSearchQuery } from "@/lib/search"; +import { searchRows } from "@/lib/search-query"; import { isVideoContentTag } from "@/lib/video-tags"; -type SearchRow = { - awemeId: string; - type: "video" | "image"; - score: number; - inDesc: boolean; - inTranscript: boolean; - rawDesc: string; - rawTranscript: string; - contentTags: string[]; - total: number; -}; - export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = (searchParams.get("q") || "").trim(); @@ -43,79 +27,12 @@ export async function GET(req: Request) { return json({ results: [], total: 0, page, limit, terms }); } - // 关键词已在 parseSearchQuery 里转为小写,这里转成正则后与 lower() 后的文本匹配 - const patterns = terms.map(termToPgPattern); - const excludePatterns = excludes.map(termToPgPattern); - try { - // 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写), - // 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。 - // 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。 - // 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。 - const rows = await prisma.$queryRaw` - WITH params AS ( - SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes - ), - docs AS ( - SELECT - v.aweme_id AS "awemeId", - 'video'::text AS type, - v."desc" AS "rawDesc", - COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript", - v.created_at AS created, - v.manual_content_tags AS "contentTags" - FROM "Video" v - LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id - WHERE ${contentTag}::text IS NULL - OR v.manual_content_tags @> ARRAY[${contentTag}]::text[] - - UNION ALL - - SELECT - ip.aweme_id, - 'image'::text, - ip."desc", - '', - ip.created_at, - ARRAY[]::text[] - FROM "ImagePost" ip - WHERE ${contentTag}::text IS NULL - ), - lowered AS ( - SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t - FROM docs - ), - matched AS ( - SELECT - l.*, - COALESCE(( - SELECT SUM( - 4 * LEAST(regexp_count(l.d, term), 3) - + LEAST(regexp_count(l.t, term), 10) - ) - FROM unnest(p.terms) term - ), 0)::float8 AS score, - EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc", - EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript" - FROM lowered l, params p - WHERE NOT EXISTS ( - SELECT 1 FROM unnest(p.terms) term - WHERE l.d !~ term AND l.t !~ term - ) - AND NOT EXISTS ( - SELECT 1 FROM unnest(p.excludes) x - WHERE l.d ~ x OR l.t ~ x - ) - ) - SELECT - "awemeId", type, score, "inDesc", "inTranscript", - "rawDesc", "rawTranscript", "contentTags", - (COUNT(*) OVER ())::int AS total - FROM matched - ORDER BY score DESC, created DESC, "awemeId" - OFFSET ${offset} - LIMIT ${limit}; - `; + const rows = await searchRows( + { terms, excludes, contentTag }, + offset, + limit, + ); const videoIds = rows .filter((r) => r.type === "video") diff --git a/app/author/[secUid]/page.tsx b/app/author/[secUid]/page.tsx index 8469acd..86e8ab5 100644 --- a/app/author/[secUid]/page.tsx +++ b/app/author/[secUid]/page.tsx @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma"; import { getFileUrl } from "@/lib/minio"; +import { playbackContextQuery } from "@/lib/playback-context"; import FeedMasonry from "@/app/components/FeedMasonry"; import BackButton from "@/app/components/BackButton"; import { FeedItem } from "@/app/types/feed"; @@ -147,6 +148,7 @@ export default async function AuthorPage({ initialItems={initialItems} initialCursor={initialCursor} fetchUrl={`/api/author/${secUid}/feed`} + linkQuery={playbackContextQuery({ kind: "author", secUid })} /> diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index f46afd3..eaa3d3b 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -10,32 +10,49 @@ import { useRef, useState, } from "react"; +import { + awemeHref, + type PlaylistItem, + playbackContextQuery, +} from "@/lib/playback-context"; +import { AuthorWorksTab } from "./components/AuthorWorksTab"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { CommentPanel } from "./components/CommentPanel"; +import { ContextChip } from "./components/ContextChip"; +import { ContextListTab } from "./components/ContextListTab"; import { ImageCarousel } from "./components/ImageCarousel"; import { ImageNavigationButtons } from "./components/ImageNavigationButtons"; import { MediaControls } from "./components/MediaControls"; import { NavigationButtons } from "./components/NavigationButtons"; +import { SidePanel } from "./components/SidePanel"; import { TranscriptPanel } from "./components/TranscriptPanel"; import { VideoPlayer } from "./components/VideoPlayer"; import { VideoTagEditor } from "./components/VideoTagEditor"; import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas"; -import { useCommentState } from "./hooks/useCommentState"; import { useImageCarousel } from "./hooks/useImageCarousel"; import { useNavigation } from "./hooks/useNavigation"; import { usePlayerState } from "./hooks/usePlayerState"; +import { usePlaylist } from "./hooks/usePlaylist"; +import { + isPortraitViewport, + useSidePanelState, +} from "./hooks/useSidePanelState"; import { useVideoPlayer } from "./hooks/useVideoPlayer"; import type { AbLoop, AwemeData, ImageData, Neighbors, + PlaybackContextInfo, VideoData, VideoTranscript, } from "./types.ts"; const SEGMENT_MS = 4000; +// 每个搜索 / hashtag 列表只在第一次进入时自动打开侧栏,之后尊重你自己的开关 +const LIST_AUTO_OPENED_KEY = "aweme_player_list_auto_opened"; + type WebkitVideoElement = HTMLVideoElement & { webkitEnterFullscreen?: () => void; }; @@ -52,13 +69,22 @@ type MediaSessionWithPositionState = MediaSession & { interface AwemeDetailClientProps { data: AwemeData; + /** 服务端按 URL 上的播放上下文算出的上一条/下一条 */ neighbors: Neighbors; + /** URL 上的上下文(搜索/标签/作者)生效时的提示信息 */ + playbackContext: PlaybackContextInfo | null; + /** URL 上原本的上下文查询串,作者模式下跳转时原样带上 */ + contextQuery: string; + authorWorksCount: number; transcript: VideoTranscript | null; } export default function AwemeDetailClient({ data, - neighbors, + neighbors: serverNeighbors, + playbackContext, + contextQuery, + authorWorksCount, transcript, }: AwemeDetailClientProps): ReactElement { const router = useRouter(); @@ -66,7 +92,59 @@ export default function AwemeDetailClient({ // 状态管理 const playerState = usePlayerState(); - const commentState = useCommentState(); + const panel = useSidePanelState(data.author.sec_uid); + // 搜索 / hashtag / 标签上下文才有「列表」标签页(作者上下文和「TA的作品」重复) + const listContext = + playbackContext && playbackContext.kind !== "author" + ? playbackContext + : null; + const activeTab = + panel.tab === "list" && !listContext ? "comments" : panel.tab; + const authorQuery = data.author.sec_uid + ? playbackContextQuery({ kind: "author", secUid: data.author.sec_uid }) + : ""; + const authorWorks = usePlaylist( + authorQuery, + panel.authorMode || (panel.open && activeTab === "works"), + ); + const contextList = usePlaylist( + listContext ? contextQuery : "", + panel.open && activeTab === "list", + ); + + // 作者模式(打开「TA的作品」时进入,关掉侧栏不退出):上下切换在该作者的作品里移动; + // 点顶部提示的 ✕ 退出后,回到服务端按 URL 上下文算好的顺序,当前视频不变。 + const authorPlayback = useMemo(() => { + if (!panel.authorMode) return null; + const items = authorWorks.items; + // 列表还没加载出来时先不让上下切换,避免误按到另一个顺序里 + if (!items) { + return { + neighbors: { prev: null, next: null } as Neighbors, + position: 0, + total: 0, + }; + } + const index = items.findIndex((item) => item.aweme_id === data.aweme_id); + if (index === -1) return null; + const link = (item: PlaylistItem | undefined) => + item + ? { + aweme_id: item.aweme_id, + href: awemeHref(item.aweme_id, contextQuery), + } + : null; + return { + neighbors: { + prev: link(items[index - 1]), + next: link(items[index + 1]), + } as Neighbors, + position: index + 1, + total: items.length, + }; + }, [panel.authorMode, authorWorks.items, data.aweme_id, contextQuery]); + + const neighbors = authorPlayback?.neighbors ?? serverNeighbors; const [transcriptOpen, setTranscriptOpen] = useState(false); const [isFastForwarding, setIsFastForwarding] = useState(false); const [abLoop, setAbLoop] = useState({ a: null, b: null }); @@ -328,23 +406,106 @@ export default function AwemeDetailClient({ setTranscriptOpen(false); }, []); - const handleCloseComments = useCallback((): void => { - commentState.setOpen(false); - }, [commentState.setOpen]); - const handleToggleComments = useCallback((): void => { - commentState.setOpen((v) => !v); - }, [commentState.setOpen]); + panel.toggle("comments"); + }, [panel.toggle]); + + const handleAuthorClick = useCallback((): void => { + panel.toggle("works"); + }, [panel.toggle]); + + // 竖屏(底部抽屉)点了作品后关掉抽屉露出视频;顺序不受影响,点顶部提示可以再打开 + const handleWorkSelect = useCallback((): void => { + if (isPortraitViewport()) panel.close(); + }, [panel.close]); + + const handleExitUrlContext = useCallback((): void => { + router.replace(awemeHref(data.aweme_id)); + }, [data.aweme_id, router]); + + const showWorks = useCallback((): void => { + panel.show("works"); + }, [panel.show]); + + // 退出作者模式:回到 URL 上原来的顺序(搜索 / hashtag / 全部作品);作品网格还开着的话一并关掉 + const exitAuthorMode = useCallback((): void => { + panel.exitAuthorMode(); + if (panel.open && panel.tab === "works") panel.close(); + }, [panel.exitAuthorMode, panel.open, panel.tab, panel.close]); + + const showList = useCallback((): void => { + panel.show("list"); + }, [panel.show]); + + // 点描述里的 hashtag:停在当前视频,上下切换改为在带这个 hashtag 的作品里移动,并打开列表 + const handleHashtagClick = useCallback( + (name: string): void => { + panel.exitAuthorMode(); + router.replace( + awemeHref( + data.aweme_id, + playbackContextQuery({ kind: "hashtag", name }), + ), + { scroll: false }, + ); + }, + [data.aweme_id, router, panel.exitAuthorMode], + ); + + // 从搜索结果进来、或点了 hashtag 时自动打开列表标签页 + const listKind = listContext?.kind; + useEffect(() => { + if (!panel.restored) return; + if (listKind !== "search" && listKind !== "hashtag") return; + try { + if (sessionStorage.getItem(LIST_AUTO_OPENED_KEY) === contextQuery) return; + sessionStorage.setItem(LIST_AUTO_OPENED_KEY, contextQuery); + } catch {} + panel.show("list"); + }, [panel.restored, panel.show, listKind, contextQuery]); + + const searchPageHref = useMemo(() => { + if (listKind !== "search") return null; + const params = new URLSearchParams(contextQuery); + params.delete("ctx"); + return `/search?${params.toString()}`; + }, [listKind, contextQuery]); + + const listVisible = panel.open && activeTab === "list"; + const worksVisible = panel.open && activeTab === "works"; + + const chip = + authorPlayback && authorPlayback.total > 0 + ? { + label: `${data.author.nickname} 的作品`, + position: authorPlayback.position, + total: authorPlayback.total, + // 作品网格没在显示时,点提示可以重新打开 + onExpand: worksVisible ? undefined : showWorks, + expandLabel: "查看TA的作品", + onExit: exitAuthorMode, + exitLabel: "退出作者模式", + } + : !panel.authorMode && playbackContext + ? { + ...playbackContext, + // 列表没在显示时,点提示可以打开侧栏里的完整列表 + onExpand: listContext && !listVisible ? showList : undefined, + expandLabel: `查看${playbackContext.listTitle}`, + onExit: handleExitUrlContext, + exitLabel: "退出当前列表,按全部作品切换", + } + : null; const handleNavigatePrev = useCallback((): void => { if (neighbors.prev) { - router.push(`/aweme/${neighbors.prev.aweme_id}`); + router.push(neighbors.prev.href); } }, [neighbors.prev, router]); const handleNavigateNext = useCallback((): void => { if (neighbors.next) { - router.push(`/aweme/${neighbors.next.aweme_id}`); + router.push(neighbors.next.href); } }, [neighbors.next, router]); @@ -586,14 +747,14 @@ export default function AwemeDetailClient({ if (!isVideo && images?.length > 1) { prevImg(); } else if (neighbors.prev) { - router.push(`/aweme/${neighbors.prev.aweme_id}`); + router.push(neighbors.prev.href); } }); ms.setActionHandler("nexttrack", () => { if (!isVideo && images?.length > 1) { nextImg(); } else if (neighbors.next) { - router.push(`/aweme/${neighbors.next.aweme_id}`); + router.push(neighbors.next.href); } }); @@ -670,9 +831,24 @@ export default function AwemeDetailClient({ )} + {/* 播放上下文提示:上下切换当前在哪个列表里移动 */} + {chip && ( + + )} + {/* 长按 2x 快进状态指示 */} {isFastForwarding && ( -
+
2.0x 快进中 @@ -740,6 +916,8 @@ export default function AwemeDetailClient({ onLoopModeChange={playerState.setLoopMode} onDownload={handleDownload} onToggleFullscreen={toggleFullscreen} + onAuthorClick={handleAuthorClick} + onHashtagClick={handleHashtagClick} />
@@ -754,14 +932,63 @@ export default function AwemeDetailClient({ /> - {/* 评论面板 */} - + ), + }, + { + key: "works", + label: "TA的作品", + count: authorWorks.items?.length ?? authorWorksCount, + content: ( + + ), + }, + ...(listContext + ? [ + { + key: "list" as const, + label: listContext.listTitle, + count: listContext.total, + content: ( + + ), + }, + ] + : []), + ]} /> {/* 转录面板 */} diff --git a/app/aweme/[awemeId]/components/AuthorWorksTab.tsx b/app/aweme/[awemeId]/components/AuthorWorksTab.tsx new file mode 100644 index 0000000..5437eac --- /dev/null +++ b/app/aweme/[awemeId]/components/AuthorWorksTab.tsx @@ -0,0 +1,63 @@ +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { memo } from "react"; +import type { PlaylistState } from "../hooks/usePlaylist"; +import type { User } from "../types.ts"; +import { WorksGrid } from "./WorksGrid"; + +interface AuthorWorksTabProps { + /** 侧栏打开且停在「TA的作品」标签页 */ + active: boolean; + author: User; + works: PlaylistState; + currentId: string; + /** 跳转时原样带上的播放上下文查询串 */ + contextQuery: string; + onSelect: () => void; +} + +// 侧栏「TA的作品」:作者在库里的全部作品。点作品就地切换;点作者名字才去独立的作者页。 +export const AuthorWorksTab = memo(function AuthorWorksTab({ + active, + author, + works, + currentId, + contextQuery, + onSelect, +}: AuthorWorksTabProps) { + const header = author.sec_uid ? ( + + {/* biome-ignore lint/performance/noImgElement: 头像来自对象存储,不走 next/image */} + + + @{author.nickname} + + + 作者主页 + + ) : ( + + @{author.nickname} + + ); + + return ( + + ); +}); diff --git a/app/aweme/[awemeId]/components/CommentPanel.tsx b/app/aweme/[awemeId]/components/CommentPanel.tsx index 63f8a4c..33a8fb2 100644 --- a/app/aweme/[awemeId]/components/CommentPanel.tsx +++ b/app/aweme/[awemeId]/components/CommentPanel.tsx @@ -1,15 +1,13 @@ -import { X } from "lucide-react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import type { Comment, User } from "../types.ts"; import { CommentList } from "./CommentList"; interface CommentPanelProps { + /** 侧栏打开且停在「评论」标签页 */ open: boolean; - onClose: () => void; author: User; createdAt: string | Date; awemeId: string; - mounted: boolean; } interface CommentsApiResponse { @@ -21,16 +19,14 @@ interface CommentsApiResponse { snapshot?: string | number; } +// 侧栏「评论」标签页的内容(外壳与标签切换在 SidePanel 里) export const CommentPanel = memo(function CommentPanel({ open, - onClose, author, createdAt, awemeId, - mounted, }: CommentPanelProps) { const [comments, setComments] = useState([]); - const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); const [hasOpened, setHasOpened] = useState(open); @@ -93,7 +89,6 @@ export const CommentPanel = memo(function CommentPanel({ return deduped; }); - setTotal(typeof data.total === "number" ? data.total : 0); const nextHasMore = Boolean(data.hasMore); hasMoreRef.current = nextHasMore; setHasMore(nextHasMore); @@ -131,7 +126,6 @@ export const CommentPanel = memo(function CommentPanel({ hasMoreRef.current = true; rankParamsRef.current = null; setComments([]); - setTotal(0); setHasMore(true); setLoading(false); @@ -174,57 +168,29 @@ export const CommentPanel = memo(function CommentPanel({ }, [open, hasMore, loadComments]); return ( - + {loading && ( +
+ 加载中... +
+ )} + {!hasMore && comments.length > 0 && ( +
+ 没有更多评论了 +
+ )} + + )} +
); }); diff --git a/app/aweme/[awemeId]/components/ContextChip.tsx b/app/aweme/[awemeId]/components/ContextChip.tsx new file mode 100644 index 0000000..e237fed --- /dev/null +++ b/app/aweme/[awemeId]/components/ContextChip.tsx @@ -0,0 +1,64 @@ +import { ChevronUp, ListVideo, X } from "lucide-react"; +import { memo } from "react"; + +interface ContextChipProps { + label: string; + position: number; + total: number; + /** 提供时整个标签可点:展开 / 打开侧栏里的完整列表 */ + onExpand?: () => void; + expandLabel?: string; + onExit: () => void; + exitLabel: string; +} + +// 顶部的模式提示:说明上下切换当前在哪个列表里移动,避免「为什么跳到这里了」的困惑 +export const ContextChip = memo(function ContextChip({ + label, + position, + total, + onExpand, + expandLabel, + onExit, + exitLabel, +}: ContextChipProps) { + const content = ( + <> + + {label} + + {position}/{total} + + {onExpand && } + + ); + + return ( +
+ {onExpand ? ( + + ) : ( +
+ {content} +
+ )} + +
+ ); +}); diff --git a/app/aweme/[awemeId]/components/ContextListTab.tsx b/app/aweme/[awemeId]/components/ContextListTab.tsx new file mode 100644 index 0000000..7909c3c --- /dev/null +++ b/app/aweme/[awemeId]/components/ContextListTab.tsx @@ -0,0 +1,60 @@ +import { Hash, Search } from "lucide-react"; +import Link from "next/link"; +import { memo } from "react"; +import type { PlaylistState } from "../hooks/usePlaylist"; +import type { PlaybackContextInfo } from "../types.ts"; +import { WorksGrid } from "./WorksGrid"; + +interface ContextListTabProps { + active: boolean; + context: PlaybackContextInfo; + list: PlaylistState; + currentId: string; + contextQuery: string; + /** 对应的搜索结果页链接(搜索上下文才有) */ + searchPageHref: string | null; + onSelect: () => void; +} + +// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致 +export const ContextListTab = memo(function ContextListTab({ + active, + context, + list, + currentId, + contextQuery, + searchPageHref, + onSelect, +}: ContextListTabProps) { + const Icon = context.kind === "hashtag" ? Hash : Search; + const header = ( + <> + +
+
{context.label}
+
+ 第 {context.position} 个 · 共 {context.total} 个 +
+
+ {searchPageHref && ( + + 在搜索页查看 + + )} + + ); + + return ( + + ); +}); diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx index 05f8c0a..2a11735 100644 --- a/app/aweme/[awemeId]/components/MediaControls.tsx +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -16,7 +16,8 @@ import { VolumeX, } from "lucide-react"; import Link from "next/link"; -import { memo, type ReactNode, type RefObject } from "react"; +import { memo, type ReactNode, type RefObject, useMemo } from "react"; +import { parseDescHashtags } from "@/lib/hashtag"; import type { AbLoop, LoopMode, ObjectFit, User } from "../types.ts"; import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils"; import { MoreMenu, MoreMenuItem } from "./MoreMenu"; @@ -59,6 +60,10 @@ interface MediaControlsProps { onLoopModeChange: (mode: LoopMode) => void; onDownload: () => void; onToggleFullscreen: () => void; + /** 点作者头像/名字:在侧栏打开「TA的作品」 */ + onAuthorClick?: () => void; + /** 点描述里的 hashtag:切到该 hashtag 的作品列表 */ + onHashtagClick?: (name: string) => void; } const CONTROL_BTN_CLASS = @@ -96,7 +101,10 @@ export const MediaControls = memo(function MediaControls({ onLoopModeChange, onDownload, onToggleFullscreen, + onAuthorClick, + onHashtagClick, }: MediaControlsProps) { + const descParts = useMemo(() => parseDescHashtags(desc || ""), [desc]); const normalizedLoopMode = isVideo && loopMode === "single" ? "loop" : loopMode; const loopLabel = isVideo @@ -129,10 +137,29 @@ export const MediaControls = memo(function MediaControls({ }; return ( -
+
{/* 描述行 */}
- {author.sec_uid ? ( + {author.sec_uid && onAuthorClick ? ( + + ) : author.sec_uid ? (

- {desc} + {descParts.map((part, i) => + part.type === "hashtag" && onHashtagClick ? ( + + ) : ( + // biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定 + {part.text} + ), + )}

) : null} diff --git a/app/aweme/[awemeId]/components/SidePanel.tsx b/app/aweme/[awemeId]/components/SidePanel.tsx new file mode 100644 index 0000000..c2cf70a --- /dev/null +++ b/app/aweme/[awemeId]/components/SidePanel.tsx @@ -0,0 +1,97 @@ +import { X } from "lucide-react"; +import { memo, type ReactNode } from "react"; +import type { SidePanelTab } from "../hooks/useSidePanelState"; + +export type SidePanelTabConfig = { + key: SidePanelTab; + label: string; + count: number; + content: ReactNode; +}; + +interface SidePanelProps { + open: boolean; + mounted: boolean; + tab: SidePanelTab; + tabs: SidePanelTabConfig[]; + onTabChange: (tab: SidePanelTab) => void; + onClose: () => void; +} + +// 右侧(竖屏为底部抽屉)的侧栏外壳:评论 / TA的作品 / 搜索结果(或 hashtag)几个标签页 +export const SidePanel = memo(function SidePanel({ + open, + mounted, + tab, + tabs, + onTabChange, + onClose, +}: SidePanelProps) { + const visibility = open + ? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10" + : "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0"; + // 竖屏时作品网格不需要那么高,给视频多留点位置 + const portraitHeight = + tab === "comments" ? "h-[min(80vh,88dvh)]" : "h-[min(62vh,64dvh)]"; + + return ( + + ); +}); diff --git a/app/aweme/[awemeId]/components/VideoTagEditor.tsx b/app/aweme/[awemeId]/components/VideoTagEditor.tsx index 4f9e00f..c346baf 100644 --- a/app/aweme/[awemeId]/components/VideoTagEditor.tsx +++ b/app/aweme/[awemeId]/components/VideoTagEditor.tsx @@ -66,7 +66,10 @@ export const VideoTagEditor = memo(function VideoTagEditor({ }; return ( -
+
)} diff --git a/app/page.tsx b/app/page.tsx index f91c374..570e3c6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags"; import { getFileUrl } from "@/lib/minio"; +import { playbackContextQuery } from "@/lib/playback-context"; import { prisma } from "@/lib/prisma"; +import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; import FeedMasonry from "./components/FeedMasonry"; import SearchBox from "./components/SearchBox"; import type { FeedItem } from "./types/feed"; @@ -142,6 +143,9 @@ export default async function Home({ key={selectedTag ?? "all"} initialItems={initial} initialCursor={cursor} + linkQuery={playbackContextQuery( + selectedTag ? { kind: "tag", tag: selectedTag } : null, + )} fetchUrl={ selectedTag ? `/api/feed?tag=${encodeURIComponent(selectedTag)}` diff --git a/app/search/SearchClient.tsx b/app/search/SearchClient.tsx index 9474c39..cde0cad 100644 --- a/app/search/SearchClient.tsx +++ b/app/search/SearchClient.tsx @@ -8,9 +8,11 @@ import { type FormEvent, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; +import { playbackContextQuery } from "@/lib/playback-context"; import type { SnippetPart } from "@/lib/search"; import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; @@ -209,6 +211,15 @@ export default function SearchClient({ router.push("/search"); }; + // 从搜索结果进入播放页时,上下切换在这次的搜索结果里移动(与当前 URL 上的搜索条件一致) + const resultLinkQuery = useMemo(() => { + const q = (searchParams.get("q") || "").trim(); + const tagParam = searchParams.get("tag") || ""; + const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null; + if (!q && !tag) return ""; + return playbackContextQuery({ kind: "search", q, tag }); + }, [searchParams]); + return (
{/* 顶部搜索栏 */} @@ -333,7 +344,7 @@ export default function SearchClient({
{/* 左侧:封面预览 */} @@ -388,7 +399,7 @@ export default function SearchClient({ {/* 描述 */}

diff --git a/lib/hashtag.ts b/lib/hashtag.ts new file mode 100644 index 0000000..5a1396a --- /dev/null +++ b/lib/hashtag.ts @@ -0,0 +1,49 @@ +// 从作品描述里解析抖音 hashtag(#xxx)。 +// 前端渲染可点击的 hashtag 与后端按 hashtag 精确筛选作品共用这里的边界规则, +// 保证「点了 #丰川祥子 看到的列表」不会混进 #丰川祥子cos。 + +// hashtag 在遇到空白、下一个 #、或这些标点时结束 +const STOP_CHARS = + "#,。!?、;:,.!?;:()()【】\\[\\]《》<>\"'“”‘’…~~|/\\\\@"; +const MAX_HASHTAG_LENGTH = 50; + +const HASHTAG_RE = new RegExp(`#([^\\s${STOP_CHARS}]+)`, "gu"); + +export type DescPart = + | { type: "text"; text: string } + | { type: "hashtag"; text: string; name: string }; + +/** 把描述切成普通文字与 hashtag 片段。 */ +export function parseDescHashtags(desc: string): DescPart[] { + const parts: DescPart[] = []; + let cursor = 0; + for (const m of desc.matchAll(HASHTAG_RE)) { + const start = m.index ?? 0; + if (start > cursor) { + parts.push({ type: "text", text: desc.slice(cursor, start) }); + } + parts.push({ type: "hashtag", text: m[0], name: m[1] }); + cursor = start + m[0].length; + } + if (cursor < desc.length) { + parts.push({ type: "text", text: desc.slice(cursor) }); + } + return parts; +} + +/** 规范化 hashtag 名(去掉开头的 #、首尾空白,限制长度);不合法时返回 null。 */ +export function normalizeHashtag(raw: string): string | null { + const name = raw.trim().replace(/^#+/, "").slice(0, MAX_HASHTAG_LENGTH); + if (!name) return null; + if (new RegExp(`[\\s${STOP_CHARS}]`, "u").test(name)) return null; + return name; +} + +/** + * 匹配 lower() 后描述文本的 Postgres 正则(ARE):#名字 之后必须是结束符或文本末尾。 + * 名字里的正则特殊字符会被转义。 + */ +export function hashtagToPgPattern(name: string): string { + const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return `#${escaped}([\\s${STOP_CHARS}]|$)`; +} diff --git a/lib/playback-context.server.ts b/lib/playback-context.server.ts new file mode 100644 index 0000000..cd3a720 --- /dev/null +++ b/lib/playback-context.server.ts @@ -0,0 +1,199 @@ +import { Prisma } from "@prisma/client"; +import { hashtagToPgPattern } from "@/lib/hashtag"; +import { getFileUrl } from "@/lib/minio"; +import { + awemeHref, + type PlaybackContext, + type PlaylistItem, + playbackContextQuery, +} from "@/lib/playback-context"; +import { prisma } from "@/lib/prisma"; +import { parseSearchQuery } from "@/lib/search"; +import { searchIds } from "@/lib/search-query"; + +export type NeighborLink = { aweme_id: string; href: string }; + +export type PlaybackNeighbors = { + prev: NeighborLink | null; + next: NeighborLink | null; +}; + +export type PlaybackContextInfo = { + kind: PlaybackContext["kind"]; + /** 顶部提示里的说明,如「搜索「百姓」」 */ + label: string; + /** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */ + listTitle: string; + /** 从 1 开始 */ + position: number; + total: number; +}; + +export type ResolvedPlayback = { + neighbors: PlaybackNeighbors; + /** 当前生效的上下文(URL 里的上下文不包含当前作品时为 null,退回全部作品) */ + context: PlaybackContextInfo | null; + /** URL 里原本的上下文查询串,侧栏里跳转时原样带上 */ + contextQuery: string; +}; + +/** 侧栏一次最多列出的作品数(搜索词很宽泛时避免一次塞几千张封面) */ +export const PLAYLIST_LIMIT = 500; + +// 全部作品 / 某作者的作品 / 带某个 hashtag 的作品:视频与图文合并,按发布时间倒序(与首页、作者页一致) +async function timelineIds(filter: Prisma.Sql = Prisma.empty) { + const rows = await prisma.$queryRaw<{ id: string }[]>` + SELECT aweme_id AS id FROM ( + SELECT aweme_id, created_at, "authorId", "desc" FROM "Video" + UNION ALL + SELECT aweme_id, created_at, "authorId", "desc" FROM "ImagePost" + ) works + ${filter} + ORDER BY created_at DESC, aweme_id DESC + `; + return rows.map((r) => r.id); +} + +// 数据量小(全库约两千个作品),直接取完整的有序 ID 列表再定位当前作品, +// 比为每种上下文分别写游标查询简单得多。数据量大了再换成游标查询,前端不用动。 +export async function contextIds( + ctx: PlaybackContext | null, +): Promise { + if (!ctx) return timelineIds(); + switch (ctx.kind) { + case "author": + return timelineIds(Prisma.sql`WHERE "authorId" = ${ctx.secUid}`); + case "hashtag": + return timelineIds( + Prisma.sql`WHERE lower("desc") ~ ${hashtagToPgPattern(ctx.name)}`, + ); + case "tag": + return searchIds({ terms: [], excludes: [], contentTag: ctx.tag }); + case "search": { + const { terms, excludes } = parseSearchQuery(ctx.q); + if (terms.length === 0 && !ctx.tag) return []; + return searchIds({ terms, excludes, contentTag: ctx.tag }); + } + } +} + +async function contextLabels( + ctx: PlaybackContext, +): Promise<{ label: string; listTitle: string }> { + switch (ctx.kind) { + case "search": + if (!ctx.q) return { label: `标签「${ctx.tag}」`, listTitle: "搜索结果" }; + return { + label: ctx.tag ? `搜索「${ctx.q}」· ${ctx.tag}` : `搜索「${ctx.q}」`, + listTitle: "搜索结果", + }; + case "tag": + return { label: `标签「${ctx.tag}」`, listTitle: ctx.tag }; + case "hashtag": + return { label: `#${ctx.name}`, listTitle: `#${ctx.name}` }; + case "author": { + const author = await prisma.author.findUnique({ + where: { sec_uid: ctx.secUid }, + select: { nickname: true }, + }); + const label = `${author?.nickname ?? "作者"} 的作品`; + return { label, listTitle: label }; + } + } +} + +function neighborsAt( + ids: string[], + index: number, + query: string, +): PlaybackNeighbors { + const link = (id: string | undefined) => + id ? { aweme_id: id, href: awemeHref(id, query) } : null; + return { prev: link(ids[index - 1]), next: link(ids[index + 1]) }; +} + +export async function resolvePlayback( + awemeId: string, + ctx: PlaybackContext | null, +): Promise { + const contextQuery = playbackContextQuery(ctx); + + if (ctx) { + const [ids, labels] = await Promise.all([ + contextIds(ctx), + contextLabels(ctx), + ]); + const index = ids.indexOf(awemeId); + if (index !== -1) { + return { + neighbors: neighborsAt(ids, index, contextQuery), + context: { + kind: ctx.kind, + ...labels, + position: index + 1, + total: ids.length, + }, + contextQuery, + }; + } + // 当前作品不在这个列表里(例如在作者模式里切到了不在搜索结果中的作品), + // 退回全部作品的顺序;继续往下切时不再带上原来的上下文。 + } + + const ids = await timelineIds(); + const index = ids.indexOf(awemeId); + return { + neighbors: + index === -1 ? { prev: null, next: null } : neighborsAt(ids, index, ""), + context: null, + contextQuery, + }; +} + +/** 按上下文顺序列出作品(供侧栏网格使用),最多 PLAYLIST_LIMIT 个。 */ +export async function contextItems( + ctx: PlaybackContext, +): Promise<{ items: PlaylistItem[]; total: number }> { + const allIds = await contextIds(ctx); + const ids = allIds.slice(0, PLAYLIST_LIMIT); + const [videos, posts] = await Promise.all([ + prisma.video.findMany({ + where: { aweme_id: { in: ids } }, + select: { aweme_id: true, desc: true, cover_url: true, digg_count: true }, + }), + prisma.imagePost.findMany({ + where: { aweme_id: { in: ids } }, + select: { + aweme_id: true, + desc: true, + digg_count: true, + images: { orderBy: { order: "asc" }, take: 1, select: { url: true } }, + }, + }), + ]); + + const byId = new Map(); + for (const v of videos) { + byId.set(v.aweme_id, { + aweme_id: v.aweme_id, + type: "video", + desc: v.desc, + cover_url: v.cover_url ? getFileUrl(v.cover_url) : null, + likes: Number(v.digg_count), + }); + } + for (const p of posts) { + byId.set(p.aweme_id, { + aweme_id: p.aweme_id, + type: "image", + desc: p.desc, + cover_url: p.images[0] ? getFileUrl(p.images[0].url) : null, + likes: Number(p.digg_count), + }); + } + + const items = ids + .map((id) => byId.get(id)) + .filter((item): item is PlaylistItem => Boolean(item)); + return { items, total: allIds.length }; +} diff --git a/lib/playback-context.ts b/lib/playback-context.ts new file mode 100644 index 0000000..ddb3c6c --- /dev/null +++ b/lib/playback-context.ts @@ -0,0 +1,82 @@ +// 播放上下文:播放页「上一条 / 下一条」在哪个列表里移动。 +// 它放在 /aweme/ 的查询参数里,刷新、新标签页打开、后退都不会丢: +// (无) 全部作品,按发布时间倒序(和首页一致) +// ?ctx=tag&tag=美女 某个标签 +// ?ctx=author&author= 某个作者的作品 +// ?ctx=search&q=百姓&tag=… 某次搜索的结果 +// ?ctx=hashtag&hashtag=丰川祥子 描述里带某个 hashtag 的作品 +// 这个文件只放纯函数,客户端组件也可以引用;查数据库的部分在 playback-context.server.ts。 +import { normalizeHashtag } from "@/lib/hashtag"; +import { isVideoContentTag } from "@/lib/video-tags"; + +export type PlaybackContext = + | { kind: "search"; q: string; tag: string | null } + | { kind: "tag"; tag: string } + | { kind: "hashtag"; name: string } + | { kind: "author"; secUid: string }; + +/** 播放页侧栏网格里的一项(/api/playlist 的返回) */ +export type PlaylistItem = { + aweme_id: string; + type: "video" | "image"; + desc: string; + cover_url: string | null; + likes: number; +}; + +export type PlaylistResponse = { items: PlaylistItem[]; total: number }; + +type ParamSource = + | URLSearchParams + | Record; + +function readParam(source: ParamSource, key: string): string { + const value = + source instanceof URLSearchParams ? source.get(key) : source[key]; + return (Array.isArray(value) ? value[0] : value)?.trim() ?? ""; +} + +export function parsePlaybackContext( + source: ParamSource, +): PlaybackContext | null { + const kind = readParam(source, "ctx"); + const tag = readParam(source, "tag"); + const validTag = tag && isVideoContentTag(tag) ? tag : null; + + if (kind === "search") { + const q = readParam(source, "q"); + if (!q && !validTag) return null; + return { kind: "search", q, tag: validTag }; + } + if (kind === "tag" && validTag) return { kind: "tag", tag: validTag }; + if (kind === "hashtag") { + const name = normalizeHashtag(readParam(source, "hashtag")); + if (name) return { kind: "hashtag", name }; + } + if (kind === "author") { + const secUid = readParam(source, "author"); + if (secUid) return { kind: "author", secUid }; + } + return null; +} + +/** 生成带上下文的查询串(含开头的 ?);没有上下文时返回空串。 */ +export function playbackContextQuery(ctx: PlaybackContext | null): string { + if (!ctx) return ""; + const params = new URLSearchParams({ ctx: ctx.kind }); + if (ctx.kind === "search") { + if (ctx.q) params.set("q", ctx.q); + if (ctx.tag) params.set("tag", ctx.tag); + } else if (ctx.kind === "tag") { + params.set("tag", ctx.tag); + } else if (ctx.kind === "hashtag") { + params.set("hashtag", ctx.name); + } else { + params.set("author", ctx.secUid); + } + return `?${params.toString()}`; +} + +export function awemeHref(awemeId: string, query = ""): string { + return `/aweme/${awemeId}${query}`; +} diff --git a/lib/search-query.ts b/lib/search-query.ts new file mode 100644 index 0000000..2798266 --- /dev/null +++ b/lib/search-query.ts @@ -0,0 +1,115 @@ +// 搜索的 SQL 部分:搜索接口(分页取结果)和播放页(取完整的有序 ID 列表算上一条/下一条) +// 共用同一段匹配与排序逻辑,保证两边顺序完全一致。 +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { termToPgPattern } from "@/lib/search"; + +export type SearchInput = { + /** 已转小写的关键词(parseSearchQuery 的结果) */ + terms: string[]; + excludes: string[]; + contentTag: string | null; +}; + +export type SearchRow = { + awemeId: string; + type: "video" | "image"; + score: number; + inDesc: boolean; + inTranscript: boolean; + rawDesc: string; + rawTranscript: string; + contentTags: string[]; + total: number; +}; + +// 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写), +// 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。 +// 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。 +// 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。 +function matchedCte({ terms, excludes, contentTag }: SearchInput) { + const patterns = terms.map(termToPgPattern); + const excludePatterns = excludes.map(termToPgPattern); + return Prisma.sql` + WITH params AS ( + SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes + ), + docs AS ( + SELECT + v.aweme_id AS "awemeId", + 'video'::text AS type, + v."desc" AS "rawDesc", + COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript", + v.created_at AS created, + v.manual_content_tags AS "contentTags" + FROM "Video" v + LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id + WHERE ${contentTag}::text IS NULL + OR v.manual_content_tags @> ARRAY[${contentTag}]::text[] + + UNION ALL + + SELECT + ip.aweme_id, + 'image'::text, + ip."desc", + '', + ip.created_at, + ARRAY[]::text[] + FROM "ImagePost" ip + WHERE ${contentTag}::text IS NULL + ), + lowered AS ( + SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t + FROM docs + ), + matched AS ( + SELECT + l.*, + COALESCE(( + SELECT SUM( + 4 * LEAST(regexp_count(l.d, term), 3) + + LEAST(regexp_count(l.t, term), 10) + ) + FROM unnest(p.terms) term + ), 0)::float8 AS score, + EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc", + EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript" + FROM lowered l, params p + WHERE NOT EXISTS ( + SELECT 1 FROM unnest(p.terms) term + WHERE l.d !~ term AND l.t !~ term + ) + AND NOT EXISTS ( + SELECT 1 FROM unnest(p.excludes) x + WHERE l.d ~ x OR l.t ~ x + ) + ) + `; +} + +const ORDER_BY = Prisma.sql`ORDER BY score DESC, created DESC, "awemeId"`; + +export function searchRows(input: SearchInput, offset: number, limit: number) { + return prisma.$queryRaw` + ${matchedCte(input)} + SELECT + "awemeId", type, score, "inDesc", "inTranscript", + "rawDesc", "rawTranscript", "contentTags", + (COUNT(*) OVER ())::int AS total + FROM matched + ${ORDER_BY} + OFFSET ${offset} + LIMIT ${limit}; + `; +} + +/** 按搜索结果顺序返回全部命中作品的 ID。 */ +export async function searchIds(input: SearchInput): Promise { + const rows = await prisma.$queryRaw<{ awemeId: string }[]>` + ${matchedCte(input)} + SELECT "awemeId" FROM matched + ${ORDER_BY}; + `; + return rows.map((r) => r.awemeId); +}