This commit is contained in:
2026-05-10 13:18:46 +08:00
parent dada5a4797
commit 1c16152708
17 changed files with 1197 additions and 99 deletions

View File

@@ -26,6 +26,7 @@ import {
} from 'lucide-react';
import {
type ComponentType,
type CSSProperties,
type PointerEvent,
type ReactNode,
useCallback,
@@ -171,6 +172,8 @@ const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
@@ -727,18 +730,100 @@ function CreationLibraryCard({
);
}
function RecommendRuntimeMeta({
function RecommendRuntimePreviewCard({
entry,
position,
}: {
entry: PlatformPublicGalleryCard;
position: 'previous' | 'next';
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
return (
<div
className="platform-recommend-runtime-preview"
aria-hidden="true"
data-preview-position={position}
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.08),rgba(0,0,0,0.42))]" />
<div className="platform-recommend-runtime-preview__body">
<span className="platform-public-work-card__kind">{typeLabel}</span>
<span className="platform-recommend-runtime-preview__title">
{displayName}
</span>
</div>
</div>
);
}
function RecommendSwipeCard({
entry,
authorAvatarUrl,
onSelectNext,
onSelectPrevious,
isActive,
visual,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onSelectNext?: () => void;
onSelectPrevious?: () => void;
isActive: boolean;
visual: ReactNode;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
}) {
return (
<div
className={`platform-recommend-swipe-card ${isActive ? 'platform-recommend-swipe-card--active' : 'platform-recommend-swipe-card--preview'}`}
data-active={isActive ? 'true' : 'false'}
>
<div className="platform-recommend-swipe-card__visual">{visual}</div>
<div className="platform-recommend-swipe-card__meta">
<RecommendRuntimeMeta
entry={entry}
authorAvatarUrl={authorAvatarUrl}
isActive={isActive}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
/>
</div>
</div>
);
}
function RecommendRuntimeMeta({
entry,
authorAvatarUrl,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
isActive?: boolean;
}) {
const swipeStartYRef = useRef<number | null>(null);
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
@@ -751,37 +836,23 @@ function RecommendRuntimeMeta({
{ label: '点赞', value: likeCount, icon: Heart },
{ label: '改造', value: remixCount, icon: MessageCircle },
];
const handlePointerEnd = (clientY: number) => {
const startY = swipeStartYRef.current;
swipeStartYRef.current = null;
if (startY === null) {
return;
}
const deltaY = clientY - startY;
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
return;
}
if (deltaY < 0) {
onSelectNext?.();
return;
}
onSelectPrevious?.();
};
return (
<section
className="platform-recommend-work-meta"
className={`platform-recommend-work-meta ${
isActive
? 'platform-recommend-work-meta--active'
: 'platform-recommend-work-meta--preview'
}`}
aria-label={`${entry.worldName} 作品信息`}
aria-hidden={!isActive}
data-active={isActive ? 'true' : 'false'}
onPointerDown={(event) => {
swipeStartYRef.current = event.clientY;
}}
onPointerUp={(event) => handlePointerEnd(event.clientY)}
onPointerCancel={() => {
swipeStartYRef.current = null;
onDragPointerDown?.(event);
}}
onPointerMove={onDragPointerMove}
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__stats">
{statItems.map(({ label, value, icon: Icon }) => (
@@ -3769,6 +3840,171 @@ export function RpgEntryHomeView({
) ??
recommendedFeedEntries[0] ??
null;
const activeRecommendIndex = activeRecommendEntry
? recommendedFeedEntries.findIndex(
(entry) =>
buildPublicGalleryCardKey(entry) ===
buildPublicGalleryCardKey(activeRecommendEntry),
)
: -1;
const previousRecommendEntry =
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
? recommendedFeedEntries[
(activeRecommendIndex - 1 + recommendedFeedEntries.length) %
recommendedFeedEntries.length
]
: null;
const nextRecommendEntry =
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
? recommendedFeedEntries[
(activeRecommendIndex + 1) % recommendedFeedEntries.length
]
: null;
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
startY: number;
dragging: boolean;
} | null>(null);
const commitRecommendDrag = useCallback(
(direction: 1 | -1) => {
if (recommendDragCommitDirection) {
return;
}
setRecommendDragCommitDirection(direction);
const panelHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const commitDistance =
panelHeight > 0 ? panelHeight : window.innerHeight;
setRecommendDragOffsetY(
direction === 1 ? -commitDistance : commitDistance,
);
window.setTimeout(() => {
if (direction === 1) {
onSelectNextRecommendEntry?.();
} else {
onSelectPreviousRecommendEntry?.();
}
setRecommendDragOffsetY(0);
setRecommendDragCommitDirection(null);
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
},
[
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
recommendDragCommitDirection,
],
);
const beginRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
if (
recommendDragCommitDirection ||
!isAuthenticated ||
!activeRecommendEntry ||
recommendedFeedEntries.length <= 1
) {
return;
}
recommendDragStartRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
dragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
activeRecommendEntry,
isAuthenticated,
recommendDragCommitDirection,
recommendedFeedEntries.length,
],
);
const moveRecommendDrag = useCallback((event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
const deltaY = event.clientY - drag.startY;
drag.dragging =
drag.dragging || Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
if (!drag.dragging) {
return;
}
event.preventDefault();
const cardHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const dragLimit =
cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
setRecommendDragOffsetY(
Math.max(
-dragLimit,
Math.min(dragLimit, deltaY),
),
);
}, []);
const endRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
event.currentTarget.releasePointerCapture?.(drag.pointerId);
recommendDragStartRef.current = null;
const deltaY = event.clientY - drag.startY;
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
setRecommendDragOffsetY(0);
return;
}
commitRecommendDrag(deltaY < 0 ? 1 : -1);
},
[commitRecommendDrag],
);
const cancelRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (drag) {
event.currentTarget.releasePointerCapture?.(drag.pointerId);
}
recommendDragStartRef.current = null;
setRecommendDragOffsetY(0);
},
[],
);
const recommendRailStyle = {
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
} satisfies CSSProperties;
const recommendRailClassName = recommendDragCommitDirection
? 'platform-recommend-swipe-rail--committing'
: recommendDragOffsetY === 0
? 'platform-recommend-swipe-rail--settled'
: 'platform-recommend-swipe-rail--dragging';
const selectNextRecommendEntry = useCallback(() => {
if (
isAuthenticated &&
activeRecommendEntry &&
recommendedFeedEntries.length > 1
) {
commitRecommendDrag(1);
return;
}
onSelectNextRecommendEntry?.();
}, [
activeRecommendEntry,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
@@ -3786,12 +4022,6 @@ export function RpgEntryHomeView({
isAuthenticated,
openRecommendGalleryDetail,
]);
const selectNextRecommendEntry = useCallback(() => {
onSelectNextRecommendEntry?.();
}, [onSelectNextRecommendEntry]);
const selectPreviousRecommendEntry = useCallback(() => {
onSelectPreviousRecommendEntry?.();
}, [onSelectPreviousRecommendEntry]);
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
@@ -3863,18 +4093,22 @@ export function RpgEntryHomeView({
</div>
) : null}
<section className="platform-recommend-runtime-panel">
{isLoadingPlatform ? (
{isLoadingPlatform ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">
...
</div>
) : !isAuthenticated && activeRecommendEntry ? (
</section>
) : !isAuthenticated && activeRecommendEntry ? (
<section className="platform-recommend-runtime-panel">
<RecommendCoverOnlyCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
onClick={openActiveRecommendEntry}
/>
) : recommendRuntimeError ? (
</section>
) : recommendRuntimeError ? (
<section className="platform-recommend-runtime-panel">
<button
type="button"
onClick={() =>
@@ -3886,25 +4120,81 @@ export function RpgEntryHomeView({
>
{recommendRuntimeError}
</button>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
</section>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">...</div>
) : (
<div className="platform-recommend-runtime-viewport">
{recommendRuntimeContent}
</div>
)}
</section>
</section>
) : activeRecommendEntry ? (
<div
ref={recommendCardStageRef}
className="platform-recommend-swipe-stage"
>
<div
className={`platform-recommend-swipe-rail ${recommendRailClassName}`}
style={recommendRailStyle}
>
{previousRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--previous">
<RecommendSwipeCard
entry={previousRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
previousRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={previousRecommendEntry}
position="previous"
/>
}
/>
</div>
) : null}
{activeRecommendEntry && isAuthenticated ? (
<RecommendRuntimeMeta
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
onSelectNext={selectNextRecommendEntry}
onSelectPrevious={selectPreviousRecommendEntry}
/>
) : !isLoadingPlatform ? (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
) : null}
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--current">
<RecommendSwipeCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
activeRecommendEntry,
)}
isActive
visual={
<div className="platform-recommend-runtime-viewport">
{recommendRuntimeContent}
</div>
}
onDragPointerDown={beginRecommendDrag}
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
/>
</div>
{nextRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--next">
<RecommendSwipeCard
entry={nextRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
nextRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={nextRecommendEntry}
position="next"
/>
}
/>
</div>
) : null}
</div>
</div>
) : (
<section className="platform-recommend-runtime-panel">
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
</section>
)}
</div>
);