Added pages reordering functionality
This commit is contained in:
parent
6413c7bdf0
commit
c3d949702c
4
.gitignore
vendored
4
.gitignore
vendored
@ -4,6 +4,10 @@ node_modules/
|
||||
*/node_modules/
|
||||
**/node_modules/
|
||||
*/build/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
frontend/public/sw.js
|
||||
frontend/next-env.d.ts
|
||||
package-lock.json
|
||||
AGENTS.md
|
||||
.codex/
|
||||
|
||||
@ -187,6 +187,18 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
// POST - Reorder pages within a project/environment
|
||||
router.post(
|
||||
'/reorder',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await Tour_pagesService.reorder(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// PUT - Update
|
||||
router.put(
|
||||
'/:id',
|
||||
|
||||
@ -12,6 +12,7 @@ const { createEntityService } = require('../factories/service.factory');
|
||||
const { downloadToBuffer, uploadBuffer } = require('./file');
|
||||
const videoProcessing = require('./videoProcessing');
|
||||
const { logger } = require('../utils/logger');
|
||||
const db = require('../db/models');
|
||||
|
||||
const projectRegenInProgress = new Set();
|
||||
const singleReverseGenerationInProgress = new Set();
|
||||
@ -26,6 +27,65 @@ const BaseService = createEntityService(Tour_pagesDBApi, {
|
||||
* Tour Pages Service with reversed video generation
|
||||
*/
|
||||
class TourPagesService extends BaseService {
|
||||
static async reorder(data, currentUser) {
|
||||
const projectId = data?.projectId || data?.project;
|
||||
const environment = data?.environment || 'dev';
|
||||
const orderedPageIds = Array.isArray(data?.orderedPageIds)
|
||||
? data.orderedPageIds
|
||||
: [];
|
||||
|
||||
if (!projectId) {
|
||||
throw { status: 400, message: 'Project is required' };
|
||||
}
|
||||
|
||||
if (!orderedPageIds.length) {
|
||||
throw { status: 400, message: 'orderedPageIds is required' };
|
||||
}
|
||||
|
||||
if (environment !== 'dev') {
|
||||
throw {
|
||||
status: 400,
|
||||
message:
|
||||
'Page order can only be changed in dev. Use Save to Stage and Publish to update presentations.',
|
||||
};
|
||||
}
|
||||
|
||||
return db.sequelize.transaction(async (transaction) => {
|
||||
const existingPages = await db.tour_pages.findAll({
|
||||
where: { projectId, environment },
|
||||
attributes: ['id'],
|
||||
transaction,
|
||||
});
|
||||
const existingIds = existingPages.map((page) => page.id);
|
||||
const existingIdSet = new Set(existingIds);
|
||||
const requestedIdSet = new Set(orderedPageIds);
|
||||
|
||||
if (
|
||||
existingIds.length !== orderedPageIds.length ||
|
||||
requestedIdSet.size !== orderedPageIds.length ||
|
||||
orderedPageIds.some((id) => !existingIdSet.has(id))
|
||||
) {
|
||||
throw {
|
||||
status: 400,
|
||||
message:
|
||||
'orderedPageIds must include each page from the selected project/environment exactly once',
|
||||
};
|
||||
}
|
||||
|
||||
const updatedPages = [];
|
||||
for (const [index, pageId] of orderedPageIds.entries()) {
|
||||
const page = await Tour_pagesDBApi.partialUpdate(
|
||||
pageId,
|
||||
{ sort_order: index + 1 },
|
||||
{ currentUser, transaction },
|
||||
);
|
||||
updatedPages.push(page);
|
||||
}
|
||||
|
||||
return updatedPages;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tour page - generate reversed videos if needed
|
||||
*/
|
||||
|
||||
6
frontend/next-env.d.ts
vendored
6
frontend/next-env.d.ts
vendored
@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./build/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
File diff suppressed because one or more lines are too long
@ -17,6 +17,7 @@ import {
|
||||
mdiExitToApp,
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiChevronUp,
|
||||
mdiMusicNote,
|
||||
mdiVideo,
|
||||
mdiInformationOutline,
|
||||
@ -39,6 +40,8 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
pages,
|
||||
activePageId,
|
||||
onPageChange,
|
||||
onMovePage,
|
||||
isReorderingPages = false,
|
||||
interactionMode,
|
||||
onModeChange,
|
||||
onSelectMenuItem,
|
||||
@ -65,6 +68,31 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
// Refs for ClickOutside exclusion (following NavBarItem pattern)
|
||||
const bgTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const elementsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const sortedPages = [...pages].sort((a, b) => {
|
||||
const orderA =
|
||||
typeof a.sort_order === 'number'
|
||||
? a.sort_order
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB =
|
||||
typeof b.sort_order === 'number'
|
||||
? b.sort_order
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
const activePageIndex = sortedPages.findIndex(
|
||||
(page) => page.id === activePageId,
|
||||
);
|
||||
const canMovePageUp =
|
||||
Boolean(onMovePage) &&
|
||||
!isReorderingPages &&
|
||||
activePageIndex > 0 &&
|
||||
sortedPages.length > 1;
|
||||
const canMovePageDown =
|
||||
Boolean(onMovePage) &&
|
||||
!isReorderingPages &&
|
||||
activePageIndex >= 0 &&
|
||||
activePageIndex < sortedPages.length - 1;
|
||||
|
||||
// Keyboard handling (Escape closes dropdown)
|
||||
useEffect(() => {
|
||||
@ -143,8 +171,32 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onPageChange={onPageChange}
|
||||
disabled={isReorderingPages}
|
||||
/>
|
||||
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('up')}
|
||||
disabled={!canMovePageUp}
|
||||
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
|
||||
title='Move page up'
|
||||
aria-label='Move page up'
|
||||
>
|
||||
<BaseIcon path={mdiChevronUp} size={22} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('down')}
|
||||
disabled={!canMovePageDown}
|
||||
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
|
||||
title='Move page down'
|
||||
aria-label='Move page down'
|
||||
>
|
||||
<BaseIcon path={mdiChevronDown} size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode Toggle - reuse with compact=true */}
|
||||
<InteractionModeToggle
|
||||
mode={interactionMode}
|
||||
|
||||
@ -55,7 +55,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
||||
>
|
||||
{sortedPages.map((page, index) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.name || `Page ${index + 1}`}
|
||||
{`${index + 1}. ${page.name || `Page ${index + 1}`}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@ -251,6 +251,8 @@ export interface ConstructorToolbarProps {
|
||||
pages: TourPage[];
|
||||
activePageId: string;
|
||||
onPageChange: (pageId: string) => void;
|
||||
onMovePage?: (direction: 'up' | 'down') => void;
|
||||
isReorderingPages?: boolean;
|
||||
|
||||
// Mode toggle (reuse InteractionModeToggle with compact=true)
|
||||
interactionMode: ConstructorInteractionMode;
|
||||
|
||||
@ -36,12 +36,7 @@ class BackgroundAudioController {
|
||||
|
||||
setWaitingForInteraction(waiting: boolean): void {
|
||||
this.waitingForInteraction = waiting;
|
||||
if (
|
||||
waiting &&
|
||||
this.hasUserInteracted &&
|
||||
!this.muted &&
|
||||
this.audioElement
|
||||
) {
|
||||
if (waiting && this.hasUserInteracted && !this.muted && this.audioElement) {
|
||||
this.audioElement.play().catch(() => undefined);
|
||||
this.waitingForInteraction = false;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, {
|
||||
@ -108,6 +109,7 @@ import {
|
||||
import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
||||
import { useNetworkAware } from '../hooks/useNetworkAware';
|
||||
import { queryClient, queryKeys } from '../lib/queryClient';
|
||||
|
||||
// TourPage type is imported from '../types/entities'
|
||||
// NavigationElementType is imported from '../context/ConstructorContext'
|
||||
@ -118,6 +120,16 @@ type ConstructorPageProps = {
|
||||
|
||||
type ConstructorInteractionMode = 'edit' | 'interact';
|
||||
|
||||
const sortTourPagesForDisplay = (items: TourPage[]) =>
|
||||
[...items].sort((a, b) => {
|
||||
const orderA =
|
||||
typeof a.sort_order === 'number' ? a.sort_order : Number.MAX_SAFE_INTEGER;
|
||||
const orderB =
|
||||
typeof b.sort_order === 'number' ? b.sort_order : Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
||||
const labelByType = ELEMENT_TYPE_LABELS;
|
||||
|
||||
@ -131,6 +143,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const elementEditorRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [isAuthReady, setIsAuthReady] = useState(false);
|
||||
const [isReorderingPages, setIsReorderingPages] = useState(false);
|
||||
const isElementEditMode = mode === 'element_edit';
|
||||
|
||||
const projectId = useMemo(() => {
|
||||
@ -892,6 +905,73 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
}
|
||||
}, [activePageId, pages, refetchData]);
|
||||
|
||||
const handleMovePage = useCallback(
|
||||
async (direction: 'up' | 'down') => {
|
||||
if (!projectId || !activePageId || isReorderingPages) return;
|
||||
|
||||
const sortedPages = sortTourPagesForDisplay(pages);
|
||||
const currentIndex = sortedPages.findIndex(
|
||||
(page) => page.id === activePageId,
|
||||
);
|
||||
const targetIndex =
|
||||
direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
|
||||
if (
|
||||
currentIndex < 0 ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= sortedPages.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedPages = [...sortedPages];
|
||||
const [movedPage] = reorderedPages.splice(currentIndex, 1);
|
||||
reorderedPages.splice(targetIndex, 0, movedPage);
|
||||
|
||||
try {
|
||||
setIsReorderingPages(true);
|
||||
setErrorMessage('');
|
||||
await axios.post('/tour_pages/reorder', {
|
||||
data: {
|
||||
projectId,
|
||||
environment: activePage?.environment || 'dev',
|
||||
orderedPageIds: reorderedPages.map((page) => page.id),
|
||||
},
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.tourPages.all,
|
||||
});
|
||||
await handleReload();
|
||||
setActivePageId(activePageId);
|
||||
setSuccessMessage('Page order updated.');
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
'Failed to reorder pages.';
|
||||
setErrorMessage(message);
|
||||
logger.error(
|
||||
'Failed to reorder pages:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
} finally {
|
||||
setIsReorderingPages(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
activePage?.environment,
|
||||
activePageId,
|
||||
handleReload,
|
||||
isReorderingPages,
|
||||
pages,
|
||||
projectId,
|
||||
],
|
||||
);
|
||||
|
||||
// Page actions (save, create page, save to stage)
|
||||
const {
|
||||
isSaving,
|
||||
@ -1912,6 +1992,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page) switchToPage(page);
|
||||
}}
|
||||
onMovePage={handleMovePage}
|
||||
isReorderingPages={isReorderingPages}
|
||||
interactionMode={constructorInteractionMode}
|
||||
onModeChange={setConstructorInteractionMode}
|
||||
onSelectMenuItem={selectMenuItemForEdit}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user