优化角色动作预览加载与交互
后台和创作主页播放器只挂载当前帧与有界预读帧,避免一次换签全部序列。 浏览器播放间隔限制在定时器安全上限,不恢复已移除的最低帧间隔。 创作主页分别跟踪悬停和焦点,并遵循系统减少动态效果偏好。 后台播放按钮扩展为触控友好的四十四像素点击区域。 补充预读窗口、交互状态和减少动态效果回归测试与项目记忆。
This commit is contained in:
@@ -269,6 +269,13 @@ function AdminEditorImageSequencePreview({
|
||||
const currentFrameItem =
|
||||
frameItems[Math.min(frameIndex, frameItems.length - 1)];
|
||||
const currentFrameKey = currentFrameItem?.key ?? '';
|
||||
const mountedFrameKeys = new Set([visibleFrameKey]);
|
||||
for (let offset = 0; offset < Math.min(3, frameItems.length); offset += 1) {
|
||||
const item = frameItems[(frameIndex + offset) % frameItems.length];
|
||||
if (item) {
|
||||
mountedFrameKeys.add(item.key);
|
||||
}
|
||||
}
|
||||
|
||||
const handleFrameReady = useCallback((frameKey: string) => {
|
||||
setLoadedFrameKeys((currentKeys) => {
|
||||
@@ -303,7 +310,7 @@ function AdminEditorImageSequencePreview({
|
||||
if (!isPlaying) {
|
||||
return undefined;
|
||||
}
|
||||
const frameIntervalMs = durationMs / frames.length;
|
||||
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
|
||||
const timer = window.setInterval(() => {
|
||||
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
|
||||
}, frameIntervalMs);
|
||||
@@ -315,18 +322,20 @@ function AdminEditorImageSequencePreview({
|
||||
aria-label={`角色动作预览:${entry.label || entry.assetId}`}
|
||||
className="admin-asset-query-preview-media admin-asset-query-sequence-preview"
|
||||
>
|
||||
{frameItems.map((item) => (
|
||||
<AdminEditorImageSequenceFrame
|
||||
key={item.key}
|
||||
entry={entry}
|
||||
frame={item.frame}
|
||||
frameKey={item.key}
|
||||
visible={item.key === visibleFrameKey}
|
||||
loaded={loadedFrameKeys.has(item.key)}
|
||||
token={token}
|
||||
onReady={handleFrameReady}
|
||||
/>
|
||||
))}
|
||||
{frameItems
|
||||
.filter((item) => mountedFrameKeys.has(item.key))
|
||||
.map((item) => (
|
||||
<AdminEditorImageSequenceFrame
|
||||
key={item.key}
|
||||
entry={entry}
|
||||
frame={item.frame}
|
||||
frameKey={item.key}
|
||||
visible={item.key === visibleFrameKey}
|
||||
loaded={loadedFrameKeys.has(item.key)}
|
||||
token={token}
|
||||
onReady={handleFrameReady}
|
||||
/>
|
||||
))}
|
||||
<div className="admin-asset-query-sequence-controls">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1098,6 +1098,30 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc:
|
||||
'/generated-animations/editor/source-1/task-1/frame02.png',
|
||||
objectKey:
|
||||
'generated-animations/editor/source-1/task-1/frame02.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc:
|
||||
'/generated-animations/editor/source-1/task-1/frame03.png',
|
||||
objectKey:
|
||||
'generated-animations/editor/source-1/task-1/frame03.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc:
|
||||
'/generated-animations/editor/source-1/task-1/frame04.png',
|
||||
objectKey:
|
||||
'generated-animations/editor/source-1/task-1/frame04.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 250,
|
||||
},
|
||||
@@ -1126,15 +1150,15 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
||||
expect(
|
||||
await user.click(
|
||||
await within(dialog).findByRole('button', {
|
||||
name: '暂停角色动作',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
dialog.querySelectorAll('.admin-asset-query-sequence-frame'),
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(3);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
|
||||
@@ -1144,10 +1168,15 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
|
||||
objectKey: 'generated-animations/editor/source-1/task-1/frame01.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-animations/editor/source-1/task-1/frame02.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-animations/editor/source-1/task-1/frame03.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: '暂停角色动作' }),
|
||||
);
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '播放角色动作' }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -1737,6 +1737,8 @@ button:disabled {
|
||||
|
||||
.admin-asset-query-sequence-controls button {
|
||||
display: grid;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
|
||||
@@ -3892,6 +3892,7 @@
|
||||
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
|
||||
- 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
|
||||
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。
|
||||
|
||||
## 角色动作不能靠素材主图或通用生成输入恢复
|
||||
|
||||
- 现象:角色动作在整画布导出时正常,但从素材库单项下载只得到第一帧 PNG,拖回画布也成为普通静态图片。
|
||||
@@ -3912,7 +3913,7 @@
|
||||
|
||||
- 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。
|
||||
- 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。
|
||||
- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后才逐帧换签,避免再次制造换签突发。
|
||||
- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。
|
||||
- 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。
|
||||
|
||||
## 可复用资源回填必须保持时间戳单调
|
||||
|
||||
@@ -506,6 +506,21 @@ describe('CreationLandingView', () => {
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-03.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-04.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-05.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 250,
|
||||
},
|
||||
@@ -526,11 +541,23 @@ describe('CreationLandingView', () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(3);
|
||||
});
|
||||
expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 50)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
fireEvent.pointerEnter(openButton);
|
||||
fireEvent.blur(openButton);
|
||||
expect(
|
||||
setIntervalSpy.mock.calls.some(([, delay]) => delay === 125),
|
||||
).toBe(true);
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(3);
|
||||
fireEvent.pointerLeave(openButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
await user.click(openButton);
|
||||
expect(
|
||||
@@ -540,15 +567,76 @@ describe('CreationLandingView', () => {
|
||||
document.querySelectorAll(
|
||||
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(3);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: '播放角色动作' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy();
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps character action previews paused when reduced motion is requested', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
);
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'resource-action-reduced-motion',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-action-reduced-motion',
|
||||
label: '缓慢待机动作',
|
||||
imageSrc: '/public/action/frame-01.png',
|
||||
objectKey: null,
|
||||
width: 192,
|
||||
height: 256,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
generationCostMudPoints: 103,
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '/public/action/frame-01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-02.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
renderCreationLanding();
|
||||
const title = await screen.findByText('缓慢待机动作');
|
||||
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
|
||||
const openButton = card.querySelector(
|
||||
'.creation-landing__asset-card-open',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
fireEvent.focus(openButton);
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(0);
|
||||
|
||||
await user.click(openButton);
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '播放角色动作' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('likes a featured asset from the list card action', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
|
||||
@@ -80,6 +80,30 @@ const CREATION_HOME_ASSETS = {
|
||||
} as const;
|
||||
|
||||
const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png';
|
||||
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
|
||||
typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches
|
||||
: false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
const mediaQuery = window.matchMedia(REDUCED_MOTION_MEDIA_QUERY);
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setPrefersReducedMotion(event.matches);
|
||||
};
|
||||
setPrefersReducedMotion(mediaQuery.matches);
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
||||
const CREATION_FEATURES: CreationFeatureItem[] = [
|
||||
{
|
||||
@@ -377,7 +401,7 @@ function ShowcaseImageSequencePreview({
|
||||
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
|
||||
const firstFrameKey = frameItems[0]?.key ?? '';
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(shouldMountSequence);
|
||||
const [isPlaying, setIsPlaying] = useState(playing);
|
||||
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
@@ -385,6 +409,13 @@ function ShowcaseImageSequencePreview({
|
||||
const currentFrameItem =
|
||||
frameItems[Math.min(frameIndex, frameItems.length - 1)];
|
||||
const currentFrameKey = currentFrameItem?.key ?? '';
|
||||
const mountedFrameKeys = new Set([visibleFrameKey]);
|
||||
for (let offset = 0; offset < Math.min(3, frameItems.length); offset += 1) {
|
||||
const item = frameItems[(frameIndex + offset) % frameItems.length];
|
||||
if (item) {
|
||||
mountedFrameKeys.add(item.key);
|
||||
}
|
||||
}
|
||||
|
||||
const handleFrameReady = useCallback((frameKey: string) => {
|
||||
setLoadedFrameKeys((currentKeys) => {
|
||||
@@ -401,8 +432,8 @@ function ShowcaseImageSequencePreview({
|
||||
setFrameIndex(0);
|
||||
setLoadedFrameKeys(new Set());
|
||||
setVisibleFrameKey(firstFrameKey);
|
||||
setIsPlaying(shouldMountSequence);
|
||||
}, [firstFrameKey, frameSequenceKey, shouldMountSequence]);
|
||||
setIsPlaying(playing);
|
||||
}, [firstFrameKey, frameSequenceKey, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFrameKey) {
|
||||
@@ -419,7 +450,7 @@ function ShowcaseImageSequencePreview({
|
||||
if (!isPlaying || frames.length < 2 || durationMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const frameIntervalMs = durationMs / frames.length;
|
||||
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
|
||||
const timer = window.setInterval(() => {
|
||||
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
|
||||
}, frameIntervalMs);
|
||||
@@ -440,17 +471,19 @@ function ShowcaseImageSequencePreview({
|
||||
aria-label={`角色动作预览:${preview.label}`}
|
||||
className={`${mediaClassName} creation-landing__image-sequence`}
|
||||
>
|
||||
{frameItems.map((item) => (
|
||||
<ShowcaseImageSequenceFrame
|
||||
key={item.key}
|
||||
preview={preview}
|
||||
frame={item.frame}
|
||||
frameKey={item.key}
|
||||
visible={item.key === visibleFrameKey}
|
||||
loaded={loadedFrameKeys.has(item.key)}
|
||||
onReady={handleFrameReady}
|
||||
/>
|
||||
))}
|
||||
{frameItems
|
||||
.filter((item) => mountedFrameKeys.has(item.key))
|
||||
.map((item) => (
|
||||
<ShowcaseImageSequenceFrame
|
||||
key={item.key}
|
||||
preview={preview}
|
||||
frame={item.frame}
|
||||
frameKey={item.key}
|
||||
visible={item.key === visibleFrameKey}
|
||||
loaded={loadedFrameKeys.has(item.key)}
|
||||
onReady={handleFrameReady}
|
||||
/>
|
||||
))}
|
||||
{variant === 'modal' ? (
|
||||
<div className="creation-landing__image-sequence-controls">
|
||||
<button
|
||||
@@ -570,11 +603,13 @@ function ShowcaseBundlePreview({
|
||||
function CreationShowcaseModal({
|
||||
item,
|
||||
activeIndex,
|
||||
playSequences,
|
||||
onSelectIndex,
|
||||
onClose,
|
||||
}: {
|
||||
item: ShowcaseAssetItem | null;
|
||||
activeIndex: number;
|
||||
playSequences: boolean;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -611,7 +646,7 @@ function CreationShowcaseModal({
|
||||
<ShowcasePreview
|
||||
preview={activePreview}
|
||||
variant="modal"
|
||||
sequencePlaying
|
||||
sequencePlaying={playSequences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -679,6 +714,7 @@ export function CreationLandingView({
|
||||
onOpenCommunity,
|
||||
searchKeyword = '',
|
||||
}: CreationLandingViewProps) {
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
|
||||
@@ -706,7 +742,9 @@ export function CreationLandingView({
|
||||
useState<ShowcaseTabId>('all');
|
||||
const [selectedShowcaseItem, setSelectedShowcaseItem] =
|
||||
useState<ShowcaseAssetItem | null>(null);
|
||||
const [activeShowcaseSequenceCardId, setActiveShowcaseSequenceCardId] =
|
||||
const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] =
|
||||
useState<string | null>(null);
|
||||
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
|
||||
useState<string | null>(null);
|
||||
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
|
||||
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
|
||||
@@ -1095,16 +1133,16 @@ export function CreationLandingView({
|
||||
type="button"
|
||||
className="creation-landing__asset-card-open"
|
||||
onPointerEnter={() =>
|
||||
setActiveShowcaseSequenceCardId(item.id)
|
||||
setHoveredShowcaseSequenceCardId(item.id)
|
||||
}
|
||||
onPointerLeave={() =>
|
||||
setActiveShowcaseSequenceCardId((current) =>
|
||||
setHoveredShowcaseSequenceCardId((current) =>
|
||||
current === item.id ? null : current,
|
||||
)
|
||||
}
|
||||
onFocus={() => setActiveShowcaseSequenceCardId(item.id)}
|
||||
onFocus={() => setFocusedShowcaseSequenceCardId(item.id)}
|
||||
onBlur={() =>
|
||||
setActiveShowcaseSequenceCardId((current) =>
|
||||
setFocusedShowcaseSequenceCardId((current) =>
|
||||
current === item.id ? null : current,
|
||||
)
|
||||
}
|
||||
@@ -1116,7 +1154,9 @@ export function CreationLandingView({
|
||||
<ShowcaseBundlePreview
|
||||
item={item}
|
||||
playSequences={
|
||||
activeShowcaseSequenceCardId === item.id &&
|
||||
!prefersReducedMotion &&
|
||||
(hoveredShowcaseSequenceCardId === item.id ||
|
||||
focusedShowcaseSequenceCardId === item.id) &&
|
||||
!selectedShowcaseItem
|
||||
}
|
||||
/>
|
||||
@@ -1374,6 +1414,7 @@ export function CreationLandingView({
|
||||
<CreationShowcaseModal
|
||||
item={selectedShowcaseItem}
|
||||
activeIndex={selectedShowcaseIndex}
|
||||
playSequences={!prefersReducedMotion}
|
||||
onSelectIndex={setSelectedShowcaseIndex}
|
||||
onClose={() => setSelectedShowcaseItem(null)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user