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/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
*/build/
|
*/build/
|
||||||
|
frontend/.next/
|
||||||
|
frontend/out/
|
||||||
|
frontend/public/sw.js
|
||||||
|
frontend/next-env.d.ts
|
||||||
package-lock.json
|
package-lock.json
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.codex/
|
.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
|
// PUT - Update
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const { createEntityService } = require('../factories/service.factory');
|
|||||||
const { downloadToBuffer, uploadBuffer } = require('./file');
|
const { downloadToBuffer, uploadBuffer } = require('./file');
|
||||||
const videoProcessing = require('./videoProcessing');
|
const videoProcessing = require('./videoProcessing');
|
||||||
const { logger } = require('../utils/logger');
|
const { logger } = require('../utils/logger');
|
||||||
|
const db = require('../db/models');
|
||||||
|
|
||||||
const projectRegenInProgress = new Set();
|
const projectRegenInProgress = new Set();
|
||||||
const singleReverseGenerationInProgress = new Set();
|
const singleReverseGenerationInProgress = new Set();
|
||||||
@ -26,6 +27,65 @@ const BaseService = createEntityService(Tour_pagesDBApi, {
|
|||||||
* Tour Pages Service with reversed video generation
|
* Tour Pages Service with reversed video generation
|
||||||
*/
|
*/
|
||||||
class TourPagesService extends BaseService {
|
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
|
* 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,
|
mdiExitToApp,
|
||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
|
mdiChevronUp,
|
||||||
mdiMusicNote,
|
mdiMusicNote,
|
||||||
mdiVideo,
|
mdiVideo,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
@ -39,6 +40,8 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
pages,
|
pages,
|
||||||
activePageId,
|
activePageId,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onMovePage,
|
||||||
|
isReorderingPages = false,
|
||||||
interactionMode,
|
interactionMode,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onSelectMenuItem,
|
onSelectMenuItem,
|
||||||
@ -65,6 +68,31 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
// Refs for ClickOutside exclusion (following NavBarItem pattern)
|
// Refs for ClickOutside exclusion (following NavBarItem pattern)
|
||||||
const bgTriggerRef = useRef<HTMLButtonElement>(null);
|
const bgTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const elementsTriggerRef = 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)
|
// Keyboard handling (Escape closes dropdown)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -143,8 +171,32 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
pages={pages}
|
pages={pages}
|
||||||
activePageId={activePageId}
|
activePageId={activePageId}
|
||||||
onPageChange={onPageChange}
|
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 */}
|
{/* Mode Toggle - reuse with compact=true */}
|
||||||
<InteractionModeToggle
|
<InteractionModeToggle
|
||||||
mode={interactionMode}
|
mode={interactionMode}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
|||||||
>
|
>
|
||||||
{sortedPages.map((page, index) => (
|
{sortedPages.map((page, index) => (
|
||||||
<option key={page.id} value={page.id}>
|
<option key={page.id} value={page.id}>
|
||||||
{page.name || `Page ${index + 1}`}
|
{`${index + 1}. ${page.name || `Page ${index + 1}`}`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -251,6 +251,8 @@ export interface ConstructorToolbarProps {
|
|||||||
pages: TourPage[];
|
pages: TourPage[];
|
||||||
activePageId: string;
|
activePageId: string;
|
||||||
onPageChange: (pageId: string) => void;
|
onPageChange: (pageId: string) => void;
|
||||||
|
onMovePage?: (direction: 'up' | 'down') => void;
|
||||||
|
isReorderingPages?: boolean;
|
||||||
|
|
||||||
// Mode toggle (reuse InteractionModeToggle with compact=true)
|
// Mode toggle (reuse InteractionModeToggle with compact=true)
|
||||||
interactionMode: ConstructorInteractionMode;
|
interactionMode: ConstructorInteractionMode;
|
||||||
|
|||||||
@ -36,12 +36,7 @@ class BackgroundAudioController {
|
|||||||
|
|
||||||
setWaitingForInteraction(waiting: boolean): void {
|
setWaitingForInteraction(waiting: boolean): void {
|
||||||
this.waitingForInteraction = waiting;
|
this.waitingForInteraction = waiting;
|
||||||
if (
|
if (waiting && this.hasUserInteracted && !this.muted && this.audioElement) {
|
||||||
waiting &&
|
|
||||||
this.hasUserInteracted &&
|
|
||||||
!this.muted &&
|
|
||||||
this.audioElement
|
|
||||||
) {
|
|
||||||
this.audioElement.play().catch(() => undefined);
|
this.audioElement.play().catch(() => undefined);
|
||||||
this.waitingForInteraction = false;
|
this.waitingForInteraction = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
|
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import React, {
|
import React, {
|
||||||
@ -108,6 +109,7 @@ import {
|
|||||||
import { useCanvasScale } from '../hooks/useCanvasScale';
|
import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||||
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
||||||
import { useNetworkAware } from '../hooks/useNetworkAware';
|
import { useNetworkAware } from '../hooks/useNetworkAware';
|
||||||
|
import { queryClient, queryKeys } from '../lib/queryClient';
|
||||||
|
|
||||||
// TourPage type is imported from '../types/entities'
|
// TourPage type is imported from '../types/entities'
|
||||||
// NavigationElementType is imported from '../context/ConstructorContext'
|
// NavigationElementType is imported from '../context/ConstructorContext'
|
||||||
@ -118,6 +120,16 @@ type ConstructorPageProps = {
|
|||||||
|
|
||||||
type ConstructorInteractionMode = 'edit' | 'interact';
|
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
|
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
||||||
const labelByType = ELEMENT_TYPE_LABELS;
|
const labelByType = ELEMENT_TYPE_LABELS;
|
||||||
|
|
||||||
@ -131,6 +143,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const elementEditorRef = useRef<HTMLDivElement>(null);
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const [isAuthReady, setIsAuthReady] = useState(false);
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
||||||
|
const [isReorderingPages, setIsReorderingPages] = useState(false);
|
||||||
const isElementEditMode = mode === 'element_edit';
|
const isElementEditMode = mode === 'element_edit';
|
||||||
|
|
||||||
const projectId = useMemo(() => {
|
const projectId = useMemo(() => {
|
||||||
@ -892,6 +905,73 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
}
|
}
|
||||||
}, [activePageId, pages, refetchData]);
|
}, [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)
|
// Page actions (save, create page, save to stage)
|
||||||
const {
|
const {
|
||||||
isSaving,
|
isSaving,
|
||||||
@ -1912,6 +1992,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const page = pages.find((p) => p.id === pageId);
|
const page = pages.find((p) => p.id === pageId);
|
||||||
if (page) switchToPage(page);
|
if (page) switchToPage(page);
|
||||||
}}
|
}}
|
||||||
|
onMovePage={handleMovePage}
|
||||||
|
isReorderingPages={isReorderingPages}
|
||||||
interactionMode={constructorInteractionMode}
|
interactionMode={constructorInteractionMode}
|
||||||
onModeChange={setConstructorInteractionMode}
|
onModeChange={setConstructorInteractionMode}
|
||||||
onSelectMenuItem={selectMenuItemForEdit}
|
onSelectMenuItem={selectMenuItemForEdit}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user