This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -53,6 +53,16 @@ function renderPuzzleRuntime(
);
}
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: { pointerId: number; clientX: number; clientY: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
const clearedRun: PuzzleRunSnapshot = {
runId: 'run-1',
entryProfileId: 'profile-1',
@@ -159,12 +169,15 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useRealTimers();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => {
const runWithoutNext: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
run={runWithoutNext}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
@@ -172,13 +185,125 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
/>,
);
const avatar = screen.getByText('测');
const timer = screen.getByText('4:48');
const hintButton = screen.getByRole('button', { name: '提示' });
const referenceButton = screen.getByRole('button', { name: '原图' });
const freezeButton = screen.getByRole('button', { name: '冻结' });
expect(avatar.className).toContain('rounded-full');
expect(screen.getByText('测试作者')).toBeTruthy();
expect(timer.className).toContain('text-2xl');
expect(hintButton.className).toContain('h-16');
expect(referenceButton.className).toContain('h-16');
expect(freezeButton.className).toContain('h-16');
expect(screen.queryByText('等待下一关候选')).toBeNull();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
nextLevelMode: 'sameWork',
nextLevelProfileId: 'profile-1',
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithoutRecommendedNextProfile}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
const nextButton = screen.getByRole('button', { name: //u });
expect(nextButton).toBeTruthy();
fireEvent.click(nextButton);
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
profileId: 'profile-1',
levelId: 'puzzle-level-2',
});
vi.useRealTimers();
});
test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
const similarWorksRun: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: 'profile-similar-1',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'profile-similar-1',
nextLevelId: null,
recommendedNextWorks: [
{
profileId: 'profile-similar-1',
levelName: '雾海遗迹',
authorDisplayName: '星桥旅人',
themeTags: ['奇幻', '遗迹'],
coverImageSrc: null,
similarityScore: 0.91,
},
{
profileId: 'profile-similar-2',
levelName: '风塔试炼',
authorDisplayName: '晨风',
themeTags: ['奇幻', '机关'],
coverImageSrc: null,
similarityScore: 0.84,
},
{
profileId: 'profile-similar-3',
levelName: '月井秘路',
authorDisplayName: '月井守望',
themeTags: ['秘境', '魔法'],
coverImageSrc: null,
similarityScore: 0.79,
},
],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={similarWorksRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
const dialog = screen.getByRole('dialog', { name: '通关完成' });
expect(within(dialog).getByText('雾海遗迹')).toBeTruthy();
expect(within(dialog).getByText('风塔试炼')).toBeTruthy();
expect(within(dialog).getByText('月井秘路')).toBeTruthy();
expect(within(dialog).queryByRole('button', { name: '下一关' })).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
profileId: 'profile-similar-2',
});
vi.useRealTimers();
});
@@ -326,6 +451,106 @@ test('基础单块使用圆角裁剪图片', () => {
expect(basePiece?.className).toContain('rounded-[0.85rem]');
});
test('移动端点击拼图片时立即触发一次震动反馈', () => {
const originalVibrate = navigator.vibrate;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const vibrate = vi.fn();
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: vibrate,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: vi.fn(() => 1),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: vi.fn(),
});
const { container, unmount } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const piece = container.querySelector(
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
if (!piece) {
throw new Error('缺少测试拼图片');
}
act(() => {
dispatchPointerEvent(piece, 'pointerdown', {
pointerId: 1,
clientX: 100,
clientY: 100,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
expect(vibrate).toHaveBeenCalledWith([12]);
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 104,
clientY: 104,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 112,
clientY: 100,
});
});
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 100,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
unmount();
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: originalVibrate,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: originalRequestAnimationFrame,
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: originalCancelAnimationFrame,
});
});
test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockResolvedValue(clearedRun);

View File

@@ -16,13 +16,14 @@ import type {
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRecommendedNextWork,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
PuzzleRuntimePropKind,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -34,7 +35,8 @@ type PuzzleRuntimeShellProps = {
onBack: () => void;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: () => void;
onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void;
onRestartLevel?: () => void | Promise<void>;
onPauseChange?: (paused: boolean) => void | Promise<void>;
onUseProp?: (
propKind: PuzzleRuntimePropKind,
@@ -42,6 +44,11 @@ type PuzzleRuntimeShellProps = {
onTimeExpired?: () => void | Promise<void>;
};
export type PuzzleNextLevelTarget = {
profileId?: string;
levelId?: string | null;
};
type PuzzleBoardPieceViewModel = {
pieceId: string;
row: number;
@@ -260,6 +267,10 @@ function formatTimerMs(value: number | null | undefined) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function resolveAuthorAvatarLabel(authorDisplayName: string) {
return authorDisplayName.trim().slice(0, 1) || '玩';
}
function resolveActiveFreezeElapsedMs(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
@@ -309,6 +320,7 @@ const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
type PuzzlePropDialogState = {
propKind: PuzzleRuntimePropKind;
@@ -329,6 +341,19 @@ type PuzzleHintDemoState = {
offsetYPercent: number;
};
function triggerPuzzlePiecePressHapticFeedback() {
if (typeof navigator === 'undefined') {
return;
}
const vibrate = navigator.vibrate;
if (typeof vibrate !== 'function') {
return;
}
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
}
/**
* 拼图运行时壳层。
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
@@ -342,6 +367,7 @@ export function PuzzleRuntimeShell({
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
onRestartLevel,
onPauseChange,
onUseProp,
onTimeExpired,
@@ -907,6 +933,8 @@ export function PuzzleRuntimeShell({
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);
// 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。
triggerPuzzlePiecePressHapticFeedback();
dragSessionRef.current = {
pieceId,
pointerId: event.pointerId,
@@ -959,9 +987,24 @@ export function PuzzleRuntimeShell({
: runtimeStatus === 'failed'
? '失败'
: '进行中';
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
const nextLevelMode =
run.nextLevelMode ?? 'none';
const recommendedNextWorks = run.recommendedNextWorks ?? [];
const hasSimilarWorkChoices =
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
const canAdvanceDefaultNextLevel =
currentLevel.status === 'cleared' &&
(nextLevelMode === 'sameWork' ||
(nextLevelMode === 'similarWorks'
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
!hasSimilarWorkChoices
: Boolean(run.recommendedNextProfileId)));
const canShowNextAction =
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
const levelLabel = `${currentLevel.levelIndex}`;
const authorAvatarLabel = resolveAuthorAvatarLabel(
currentLevel.authorDisplayName,
);
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
@@ -974,7 +1017,11 @@ export function PuzzleRuntimeShell({
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
if (runtimeStatus !== 'playing') {
const canOpen =
propKind === 'extendTime'
? runtimeStatus === 'failed'
: runtimeStatus === 'playing';
if (!canOpen) {
return;
}
setPropConfirmError(null);
@@ -1048,6 +1095,9 @@ export function PuzzleRuntimeShell({
setIsFreezeEffectVisible(false);
}, 900);
}
if (propKind === 'extendTime') {
setTimerNowMs(Date.now());
}
};
return (
@@ -1064,7 +1114,7 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
<button
type="button"
onClick={onBack}
@@ -1074,26 +1124,34 @@ export function PuzzleRuntimeShell({
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex min-w-0 flex-col items-center gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-center backdrop-blur">
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
<div className="flex min-w-0 flex-col items-center gap-2 rounded-[1.35rem] bg-black/30 px-3 py-3 text-center backdrop-blur sm:px-5">
<div className="line-clamp-1 max-w-full text-sm font-black text-white sm:text-base">
{currentLevel.levelName}
</div>
<div className="line-clamp-1 text-xs text-white/78">
{currentLevel.authorDisplayName}
</div>
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
{levelLabel}
</div>
<div
className={`mt-1 inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-mono text-xs font-black ${
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 font-mono text-2xl font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.24)] sm:text-3xl ${
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
? 'bg-red-500/22 text-red-100'
: 'bg-white/10 text-white/86'
? 'bg-red-500/24 text-red-100'
: 'bg-white/12 text-white'
}`}
>
<Clock className="h-3.5 w-3.5" />
<Clock className="h-5 w-5 sm:h-6 sm:w-6" />
{formatTimerMs(displayRemainingMs)}
</div>
<div className="flex min-w-0 max-w-full items-center justify-center gap-2 text-white/82">
<span
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/16 bg-amber-200 text-xs font-black text-slate-950 shadow-[0_8px_20px_rgba(0,0,0,0.2)]"
aria-hidden="true"
>
{authorAvatarLabel}
</span>
<span className="min-w-0 truncate text-xs font-semibold sm:text-sm">
{currentLevel.authorDisplayName}
</span>
<span className="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-amber-100/90 sm:text-[11px]">
{levelLabel}
</span>
</div>
</div>
<button
@@ -1371,15 +1429,47 @@ export function PuzzleRuntimeShell({
</div>
</div>
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex items-center gap-2 rounded-full bg-black/32 p-1.5 backdrop-blur">
<div className="absolute bottom-0 left-0 z-20 flex w-full flex-col items-center gap-2 px-3 py-3 sm:px-4 sm:py-4">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
) : null}
{canShowNextAction ? (
<button
type="button"
disabled={isBusy}
onClick={() => {
if (hasSimilarWorkChoices) {
setDismissedClearKey(null);
setIsClearResultReady(true);
return;
}
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-bold text-slate-950 shadow-[0_14px_36px_rgba(251,191,36,0.26)] transition hover:bg-amber-100 disabled:opacity-45"
>
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
<ArrowRight className="h-4 w-4" />
</button>
) : null}
<div className="flex items-center justify-center gap-2 rounded-full bg-black/36 p-2 backdrop-blur sm:gap-3">
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('hint', '使用提示')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
>
<Lightbulb className="h-4 w-4 text-amber-100" />
<Lightbulb className="h-6 w-6 text-amber-100" />
</button>
<button
@@ -1393,59 +1483,25 @@ export function PuzzleRuntimeShell({
}
openPropDialog('reference', '查看原图');
}}
className={`inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold transition hover:bg-white/10 disabled:opacity-45 ${
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition hover:bg-white/10 disabled:opacity-45 ${
isOriginalOverlayVisible
? 'bg-sky-200 text-slate-950'
: 'text-white/86'
}`}
>
<Eye className="h-4 w-4" />
<Eye className="h-6 w-6" />
</button>
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
>
<Snowflake className="h-4 w-4 text-cyan-100" />
<Snowflake className="h-6 w-6 text-cyan-100" />
</button>
</div>
<div className="flex flex-col items-end gap-2">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
) : null}
{nextAvailable ? (
<button
type="button"
disabled={isBusy}
onClick={onAdvanceNextLevel}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<ArrowRight className="h-4 w-4" />
</button>
) : (
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
{isBusy
? '同步中...'
: runtimeStatus === 'cleared'
? '等待下一关候选'
: runtimeStatus === 'failed'
? '本关失败'
: '完成整张图即可通关'}
</div>
)}
</div>
</div>
{isClearFlashVisible ? (
@@ -1678,13 +1734,24 @@ export function PuzzleRuntimeShell({
{currentLevel.levelName}
</div>
</header>
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<footer className="grid grid-cols-2 gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onBack}
className="rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100"
disabled={isBusy}
onClick={() => {
void onRestartLevel?.();
}}
className="rounded-full border border-white/14 bg-black/24 px-4 py-2.5 text-sm font-black text-white transition hover:bg-white/10 disabled:opacity-50"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => openPropDialog('extendTime', '继续1分钟')}
className="rounded-full bg-amber-200 px-4 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-50"
>
1
</button>
</footer>
</section>
@@ -1783,23 +1850,47 @@ export function PuzzleRuntimeShell({
</div>
</div>
</div>
{hasSimilarWorkChoices ? (
<div className="mt-4">
<div className="grid gap-2 sm:grid-cols-3">
{recommendedNextWorks.slice(0, 3).map((item) => (
<PuzzleNextWorkCard
key={item.profileId}
item={item}
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({ profileId: item.profileId });
}}
/>
))}
</div>
</div>
) : null}
</div>
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
disabled={isBusy || !nextAvailable}
onClick={onAdvanceNextLevel}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</footer>
{canAdvanceDefaultNextLevel ? (
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</footer>
) : null}
</section>
</div>
) : null}
@@ -1808,4 +1899,54 @@ export function PuzzleRuntimeShell({
);
}
function PuzzleNextWorkCard({
item,
disabled,
onClick,
}: {
item: PuzzleRecommendedNextWork;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] border border-white/10 bg-white/6 text-left transition hover:border-amber-200/40 hover:bg-amber-200/10 disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
>
<div className="relative min-h-full bg-white/8 sm:aspect-[1.35]">
{item.coverImageSrc ? (
<ResolvedAssetImage
src={item.coverImageSrc}
alt={item.levelName}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
)}
<div className="absolute inset-0 bg-black/10 transition group-hover:bg-black/0" />
</div>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-black text-white">
{item.levelName}
</div>
<div className="mt-1 truncate text-xs font-semibold text-white/58">
{item.authorDisplayName}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{item.themeTags.slice(0, 2).map((tag) => (
<span
key={tag}
className="max-w-full truncate rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold text-white/64"
>
{tag}
</span>
))}
</div>
</div>
</button>
);
}
export default PuzzleRuntimeShell;