Enhance Site Builder Functionality and UI Components
- Added a new method to fetch blueprints from the API, improving data retrieval for site structures. - Updated the WizardPage component to include a progress modal for better user feedback during site structure generation. - Refactored state management in builderStore to track structure generation tasks, enhancing the overall user experience. - Replaced SyncIcon with RefreshCw in integration components for a more consistent iconography. - Improved the site structure generation prompt in utils.py to provide clearer instructions for AI-driven site architecture.
This commit is contained in:
@@ -31,6 +31,10 @@ export interface GenerateStructurePayload {
|
||||
}
|
||||
|
||||
export const builderApi = {
|
||||
async getBlueprint(blueprintId: number): Promise<SiteBlueprint> {
|
||||
const res = await client.get(`/blueprints/${blueprintId}/`);
|
||||
return res.data;
|
||||
},
|
||||
async listBlueprints(): Promise<SiteBlueprint[]> {
|
||||
const res = await client.get('/blueprints/');
|
||||
if (Array.isArray(res.data?.results)) {
|
||||
|
||||
27
site-builder/src/api/system.api.ts
Normal file
27
site-builder/src/api/system.api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_ROOT = import.meta.env.VITE_API_URL ?? 'http://localhost:8010/api';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_ROOT,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export interface TaskProgressResponse {
|
||||
state: 'PENDING' | 'PROGRESS' | 'SUCCESS' | 'FAILURE';
|
||||
meta?: {
|
||||
phase?: string;
|
||||
percentage?: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const systemApi = {
|
||||
async getTaskProgress(taskId: string): Promise<TaskProgressResponse> {
|
||||
const res = await client.get(`/v1/system/settings/task_progress/${taskId}/`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
102
site-builder/src/hooks/useTaskProgress.ts
Normal file
102
site-builder/src/hooks/useTaskProgress.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { systemApi, type TaskProgressResponse } from '../api/system.api';
|
||||
|
||||
type TaskStatus = 'idle' | 'pending' | 'processing' | 'completed' | 'error';
|
||||
|
||||
interface UseTaskProgressOptions {
|
||||
onComplete?: () => void;
|
||||
onError?: (message: string) => void;
|
||||
onUpdate?: (meta: TaskProgressResponse['meta']) => void;
|
||||
}
|
||||
|
||||
interface TaskProgressState {
|
||||
status: TaskStatus;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
export function useTaskProgress(taskId: string | null, options: UseTaskProgressOptions = {}) {
|
||||
const { onComplete, onError, onUpdate } = options;
|
||||
const [state, setState] = useState<TaskProgressState>({
|
||||
status: 'idle',
|
||||
percentage: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) {
|
||||
setState({ status: 'idle', percentage: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const mapResponseToState = (response: TaskProgressResponse): TaskProgressState => {
|
||||
const { state: taskState, meta } = response;
|
||||
const percentage = typeof meta?.percentage === 'number' ? meta.percentage : 0;
|
||||
const message = meta?.message;
|
||||
const phase = meta?.phase;
|
||||
|
||||
if (taskState === 'SUCCESS') {
|
||||
return { status: 'completed', percentage: 100, message: message ?? 'Site structure ready.', phase };
|
||||
}
|
||||
if (taskState === 'FAILURE') {
|
||||
return { status: 'error', percentage, message: meta?.error ?? message ?? 'Task failed.', phase };
|
||||
}
|
||||
if (taskState === 'PROGRESS') {
|
||||
return { status: 'processing', percentage: Math.min(percentage || 10, 95), message, phase };
|
||||
}
|
||||
return { status: 'pending', percentage, message, phase };
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await systemApi.getTaskProgress(taskId);
|
||||
|
||||
if (!isActive) return;
|
||||
|
||||
const nextState = mapResponseToState(response);
|
||||
setState(nextState);
|
||||
onUpdate?.(response.meta);
|
||||
|
||||
if (nextState.status === 'completed') {
|
||||
onComplete?.();
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
} else if (nextState.status === 'error') {
|
||||
onError?.(nextState.message ?? 'Task failed');
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isActive) return;
|
||||
const message = error instanceof Error ? error.message : 'Unable to load task progress';
|
||||
setState({ status: 'error', percentage: 0, message });
|
||||
onError?.(message);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
interval = setInterval(poll, 2000);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [taskId, onComplete, onError, onUpdate]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, PlayCircle, RefreshCw } from 'lucide-react';
|
||||
import { useBuilderStore } from '../../state/builderStore';
|
||||
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
|
||||
@@ -7,6 +7,9 @@ import { BriefStep } from './steps/BriefStep';
|
||||
import { ObjectivesStep } from './steps/ObjectivesStep';
|
||||
import { StyleStep } from './steps/StyleStep';
|
||||
import { Card } from '../../components/common/Card';
|
||||
import { ProgressModal } from '../../components/common/ProgressModal';
|
||||
import { useTaskProgress } from '../../hooks/useTaskProgress';
|
||||
import { builderApi } from '../../api/builder.api';
|
||||
|
||||
const stepTitles = ['Business', 'Brief', 'Objectives', 'Style'];
|
||||
|
||||
@@ -26,8 +29,54 @@ export function WizardPage() {
|
||||
error,
|
||||
activeBlueprint,
|
||||
refreshPages,
|
||||
structureTaskId,
|
||||
setStructureTaskId,
|
||||
} = useBuilderStore();
|
||||
const { structure } = useSiteDefinitionStore();
|
||||
const structure = useSiteDefinitionStore((state) => state.structure);
|
||||
const setStructure = useSiteDefinitionStore((state) => state.setStructure);
|
||||
const [showProgressModal, setShowProgressModal] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState('Initializing Site Builder AI…');
|
||||
|
||||
const syncLatestStructure = useCallback(async () => {
|
||||
if (!activeBlueprint) return;
|
||||
try {
|
||||
const latestBlueprint = await builderApi.getBlueprint(activeBlueprint.id);
|
||||
if (latestBlueprint.structure_json) {
|
||||
setStructure(latestBlueprint.structure_json);
|
||||
}
|
||||
await refreshPages(activeBlueprint.id);
|
||||
} catch (syncError) {
|
||||
const message = syncError instanceof Error ? syncError.message : 'Unable to sync blueprint';
|
||||
useBuilderStore.setState({ error: message });
|
||||
} finally {
|
||||
setStructureTaskId(null);
|
||||
}
|
||||
}, [activeBlueprint, refreshPages, setStructure, setStructureTaskId]);
|
||||
|
||||
const taskProgress = useTaskProgress(structureTaskId, {
|
||||
onUpdate: (meta) => {
|
||||
if (meta?.message) {
|
||||
setProgressMessage(meta.message);
|
||||
}
|
||||
},
|
||||
onComplete: async () => {
|
||||
setProgressMessage('Site structure ready!');
|
||||
await syncLatestStructure();
|
||||
setTimeout(() => setShowProgressModal(false), 500);
|
||||
},
|
||||
onError: (message) => {
|
||||
useBuilderStore.setState({ error: message });
|
||||
setShowProgressModal(false);
|
||||
setStructureTaskId(null);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (structureTaskId) {
|
||||
setShowProgressModal(true);
|
||||
setProgressMessage('Sending request to Site Builder AI…');
|
||||
}
|
||||
}, [structureTaskId]);
|
||||
|
||||
const stepComponents = useMemo(
|
||||
() => [
|
||||
@@ -112,6 +161,23 @@ export function WizardPage() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<ProgressModal
|
||||
isOpen={showProgressModal}
|
||||
onClose={() => {
|
||||
if (taskProgress.status === 'processing') {
|
||||
return;
|
||||
}
|
||||
setShowProgressModal(false);
|
||||
setStructureTaskId(null);
|
||||
}}
|
||||
title="Generating site structure"
|
||||
message={progressMessage}
|
||||
progress={{
|
||||
current: Math.round(taskProgress.percentage),
|
||||
total: 100,
|
||||
}}
|
||||
taskId={structureTaskId || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ describe('WizardPage', () => {
|
||||
const mockAddObjective = vi.fn();
|
||||
const mockRemoveObjective = vi.fn();
|
||||
const mockSubmitWizard = vi.fn();
|
||||
const mockSetStructureTaskId = vi.fn();
|
||||
const mockRefreshPages = vi.fn();
|
||||
const mockSetStructure = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -37,6 +40,7 @@ describe('WizardPage', () => {
|
||||
isSubmitting: false,
|
||||
error: undefined,
|
||||
activeBlueprint: undefined,
|
||||
structureTaskId: null,
|
||||
setField: mockSetField,
|
||||
nextStep: mockNextStep,
|
||||
previousStep: mockPreviousStep,
|
||||
@@ -45,10 +49,15 @@ describe('WizardPage', () => {
|
||||
addObjective: mockAddObjective,
|
||||
removeObjective: mockRemoveObjective,
|
||||
submitWizard: mockSubmitWizard,
|
||||
refreshPages: vi.fn(),
|
||||
refreshPages: mockRefreshPages,
|
||||
setStructureTaskId: mockSetStructureTaskId,
|
||||
});
|
||||
(useSiteDefinitionStore as any).mockReturnValue({
|
||||
structure: undefined,
|
||||
(useSiteDefinitionStore as any).mockImplementation((selector?: (state: any) => any) => {
|
||||
const state = {
|
||||
structure: undefined,
|
||||
setStructure: mockSetStructure,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +94,7 @@ describe('WizardPage', () => {
|
||||
isSubmitting: false,
|
||||
error: undefined,
|
||||
activeBlueprint: undefined,
|
||||
structureTaskId: null,
|
||||
setField: mockSetField,
|
||||
nextStep: mockNextStep,
|
||||
previousStep: mockPreviousStep,
|
||||
@@ -93,7 +103,8 @@ describe('WizardPage', () => {
|
||||
addObjective: mockAddObjective,
|
||||
removeObjective: mockRemoveObjective,
|
||||
submitWizard: mockSubmitWizard,
|
||||
refreshPages: vi.fn(),
|
||||
refreshPages: mockRefreshPages,
|
||||
setStructureTaskId: mockSetStructureTaskId,
|
||||
});
|
||||
|
||||
render(<WizardPage />);
|
||||
@@ -112,6 +123,7 @@ describe('WizardPage', () => {
|
||||
isSubmitting: false,
|
||||
error: 'Test error message',
|
||||
activeBlueprint: undefined,
|
||||
structureTaskId: null,
|
||||
setField: mockSetField,
|
||||
nextStep: mockNextStep,
|
||||
previousStep: mockPreviousStep,
|
||||
@@ -120,7 +132,8 @@ describe('WizardPage', () => {
|
||||
addObjective: mockAddObjective,
|
||||
removeObjective: mockRemoveObjective,
|
||||
submitWizard: mockSubmitWizard,
|
||||
refreshPages: vi.fn(),
|
||||
refreshPages: mockRefreshPages,
|
||||
setStructureTaskId: mockSetStructureTaskId,
|
||||
});
|
||||
|
||||
render(<WizardPage />);
|
||||
@@ -151,6 +164,7 @@ describe('WizardPage', () => {
|
||||
isSubmitting: true,
|
||||
error: undefined,
|
||||
activeBlueprint: undefined,
|
||||
structureTaskId: null,
|
||||
setField: mockSetField,
|
||||
nextStep: mockNextStep,
|
||||
previousStep: mockPreviousStep,
|
||||
@@ -159,7 +173,8 @@ describe('WizardPage', () => {
|
||||
addObjective: mockAddObjective,
|
||||
removeObjective: mockRemoveObjective,
|
||||
submitWizard: mockSubmitWizard,
|
||||
refreshPages: vi.fn(),
|
||||
refreshPages: mockRefreshPages,
|
||||
setStructureTaskId: mockSetStructureTaskId,
|
||||
});
|
||||
|
||||
render(<WizardPage />);
|
||||
|
||||
@@ -34,6 +34,7 @@ interface BuilderState {
|
||||
isSubmitting: boolean;
|
||||
error?: string;
|
||||
activeBlueprint?: SiteBlueprint;
|
||||
structureTaskId: string | null;
|
||||
pages: PageBlueprint[];
|
||||
selectedPageIds: number[];
|
||||
isGenerating: boolean;
|
||||
@@ -51,6 +52,7 @@ interface BuilderState {
|
||||
previousStep: () => void;
|
||||
reset: () => void;
|
||||
submitWizard: () => Promise<void>;
|
||||
setStructureTaskId: (taskId: string | null) => void;
|
||||
refreshPages: (blueprintId: number) => Promise<void>;
|
||||
togglePageSelection: (pageId: number) => void;
|
||||
selectAllPages: () => void;
|
||||
@@ -62,7 +64,9 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
form: defaultForm,
|
||||
currentStep: 0,
|
||||
isSubmitting: false,
|
||||
structureTaskId: null,
|
||||
pages: [],
|
||||
setStructureTaskId: (taskId) => set({ structureTaskId: taskId }),
|
||||
selectedPageIds: [],
|
||||
isGenerating: false,
|
||||
|
||||
@@ -118,7 +122,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isSubmitting: true, error: undefined });
|
||||
set({ isSubmitting: true, error: undefined, structureTaskId: null });
|
||||
try {
|
||||
const payload = {
|
||||
name: form.siteName || `Site Blueprint (${form.industry || 'New'})`,
|
||||
@@ -143,11 +147,17 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
metadata: { targetAudience: form.targetAudience },
|
||||
});
|
||||
|
||||
if (generation?.task_id) {
|
||||
set({ structureTaskId: generation.task_id });
|
||||
}
|
||||
|
||||
if (generation?.structure) {
|
||||
useSiteDefinitionStore.getState().setStructure(generation.structure);
|
||||
}
|
||||
|
||||
await get().refreshPages(blueprint.id);
|
||||
if (!generation?.task_id) {
|
||||
await get().refreshPages(blueprint.id);
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Unexpected error' });
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user