reaminig 5-t-9
This commit is contained in:
@@ -56,6 +56,27 @@ export const builderApi = {
|
||||
const res = await client.get(`/pages/?site_blueprint=${blueprintId}`);
|
||||
return Array.isArray(res.data?.results) ? res.data.results : res.data;
|
||||
},
|
||||
|
||||
async generateAllPages(
|
||||
blueprintId: number,
|
||||
options?: { pageIds?: number[]; force?: boolean },
|
||||
): Promise<{ success: boolean; pages_queued: number; task_ids: number[]; celery_task_id?: string }> {
|
||||
const res = await client.post(`/blueprints/${blueprintId}/generate_all_pages/`, {
|
||||
page_ids: options?.pageIds,
|
||||
force: options?.force || false,
|
||||
});
|
||||
return res.data?.data || res.data;
|
||||
},
|
||||
|
||||
async createTasksForPages(
|
||||
blueprintId: number,
|
||||
pageIds?: number[],
|
||||
): Promise<{ tasks: unknown[]; count: number }> {
|
||||
const res = await client.post(`/blueprints/${blueprintId}/create_tasks/`, {
|
||||
page_ids: pageIds,
|
||||
});
|
||||
return res.data?.data || res.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
118
site-builder/src/components/common/ProgressModal.css
Normal file
118
site-builder/src/components/common/ProgressModal.css
Normal file
@@ -0,0 +1,118 @@
|
||||
.progress-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.progress-modal-close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.progress-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.progress-modal-message {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-modal-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-modal-bar-track {
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-modal-bar-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-modal-bar-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-modal-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.progress-modal-spinner .spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-modal-task-id {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-modal-task-id code {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
76
site-builder/src/components/common/ProgressModal.tsx
Normal file
76
site-builder/src/components/common/ProgressModal.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import './ProgressModal.css';
|
||||
|
||||
interface ProgressModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
message?: string;
|
||||
progress?: {
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export function ProgressModal({ isOpen, onClose, title, message, progress, taskId }: ProgressModalProps) {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const progressPercent = progress ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="progress-modal-overlay" onClick={onClose}>
|
||||
<div className="progress-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="progress-modal-header">
|
||||
<h3>{title}</h3>
|
||||
<button type="button" className="progress-modal-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="progress-modal-content">
|
||||
{message && <p className="progress-modal-message">{message}</p>}
|
||||
|
||||
{progress && (
|
||||
<div className="progress-modal-bar">
|
||||
<div className="progress-modal-bar-track">
|
||||
<div
|
||||
className="progress-modal-bar-fill"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-modal-bar-text">
|
||||
{progress.current} of {progress.total} pages
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!progress && (
|
||||
<div className="progress-modal-spinner">
|
||||
<Loader2 className="spin" size={24} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskId && (
|
||||
<p className="progress-modal-task-id">
|
||||
Task ID: <code>{taskId}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Play } from 'lucide-react';
|
||||
import { builderApi } from '../../api/builder.api';
|
||||
import type { SiteBlueprint } from '../../types/siteBuilder';
|
||||
import { Card } from '../../components/common/Card';
|
||||
import { useBuilderStore } from '../../state/builderStore';
|
||||
import { ProgressModal } from '../../components/common/ProgressModal';
|
||||
|
||||
export function SiteDashboard() {
|
||||
const [blueprints, setBlueprints] = useState<SiteBlueprint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const { generateAllPages, isGenerating, generationProgress } = useBuilderStore();
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -24,32 +28,73 @@ export function SiteDashboard() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleGenerateAll = async (blueprintId: number) => {
|
||||
setShowProgress(true);
|
||||
try {
|
||||
await generateAllPages(blueprintId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate pages');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Blueprint history" description="Every generated structure is stored and can be reopened.">
|
||||
{loading && (
|
||||
<div className="sb-loading">
|
||||
<Loader2 className="spin" size={18} /> Loading blueprints…
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<Card title="Blueprint history" description="Every generated structure is stored and can be reopened.">
|
||||
{loading && (
|
||||
<div className="sb-loading">
|
||||
<Loader2 className="spin" size={18} /> Loading blueprints…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="sb-error">{error}</p>}
|
||||
{error && <p className="sb-error">{error}</p>}
|
||||
|
||||
{!loading && !blueprints.length && (
|
||||
<p className="sb-muted">You haven’t generated any sites yet. Launch the wizard to create your first one.</p>
|
||||
)}
|
||||
{!loading && !blueprints.length && (
|
||||
<p className="sb-muted">You haven't generated any sites yet. Launch the wizard to create your first one.</p>
|
||||
)}
|
||||
|
||||
<ul className="sb-blueprint-list">
|
||||
{blueprints.map((bp) => (
|
||||
<li key={bp.id}>
|
||||
<div>
|
||||
<strong>{bp.name}</strong>
|
||||
<span>{bp.description}</span>
|
||||
</div>
|
||||
<span className={`status-dot status-${bp.status}`}>{bp.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
<ul className="sb-blueprint-list">
|
||||
{blueprints.map((bp) => (
|
||||
<li key={bp.id}>
|
||||
<div>
|
||||
<strong>{bp.name}</strong>
|
||||
<span>{bp.description}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{bp.status === 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerateAll(bp.id)}
|
||||
disabled={isGenerating}
|
||||
className="sb-button sb-button--primary"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '4px 12px' }}
|
||||
>
|
||||
<Play size={14} />
|
||||
Generate All Pages
|
||||
</button>
|
||||
)}
|
||||
<span className={`status-dot status-${bp.status}`}>{bp.status}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<ProgressModal
|
||||
isOpen={showProgress}
|
||||
onClose={() => setShowProgress(false)}
|
||||
title="Generating Pages"
|
||||
message={isGenerating ? 'Generating content for all pages...' : 'Generation completed!'}
|
||||
progress={
|
||||
generationProgress
|
||||
? {
|
||||
current: generationProgress.pagesQueued,
|
||||
total: generationProgress.pagesQueued,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
taskId={generationProgress?.celeryTaskId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type StatItem,
|
||||
} from '@shared';
|
||||
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
|
||||
import { useBuilderStore } from '../../state/builderStore';
|
||||
import type { PageBlock, PageBlueprint, SiteStructure } from '../../types/siteBuilder';
|
||||
|
||||
type StructuredContent = Record<string, unknown> & {
|
||||
@@ -20,6 +21,8 @@ type StructuredContent = Record<string, unknown> & {
|
||||
|
||||
export function PreviewCanvas() {
|
||||
const { structure, pages, selectedSlug, selectPage } = useSiteDefinitionStore();
|
||||
const { selectedPageIds, togglePageSelection, selectAllPages, clearPageSelection, activeBlueprint } =
|
||||
useBuilderStore();
|
||||
|
||||
const page = useMemo(() => {
|
||||
if (structure?.pages?.length) {
|
||||
@@ -66,9 +69,68 @@ export function PreviewCanvas() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only show page selection if we have actual PageBlueprint objects with IDs
|
||||
const hasPageBlueprints = pages.length > 0 && pages.every((p) => p.id > 0);
|
||||
|
||||
const allSelected = hasPageBlueprints && pages.length > 0 && selectedPageIds.length === pages.length;
|
||||
const someSelected = hasPageBlueprints && selectedPageIds.length > 0 && selectedPageIds.length < pages.length;
|
||||
|
||||
return (
|
||||
<div className="preview-canvas">
|
||||
<div className="preview-nav">
|
||||
{hasPageBlueprints && activeBlueprint && (
|
||||
<div className="preview-page-selection" style={{ marginBottom: '12px', padding: '8px', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selectAllPages();
|
||||
} else {
|
||||
clearPageSelection();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<label style={{ cursor: 'pointer', fontSize: '14px', fontWeight: '500' }}>
|
||||
Select pages for bulk generation ({selectedPageIds.length} selected)
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{pages.map((p) => {
|
||||
const isSelected = selectedPageIds.includes(p.id);
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
background: isSelected ? '#e3f2fd' : 'white',
|
||||
border: `1px solid ${isSelected ? '#2196f3' : '#ddd'}`,
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => togglePageSelection(p.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span>{p.title || p.slug.replace('-', ' ')}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{navItems?.map((slug) => (
|
||||
<button
|
||||
key={slug}
|
||||
|
||||
@@ -35,6 +35,13 @@ interface BuilderState {
|
||||
error?: string;
|
||||
activeBlueprint?: SiteBlueprint;
|
||||
pages: PageBlueprint[];
|
||||
selectedPageIds: number[];
|
||||
isGenerating: boolean;
|
||||
generationProgress?: {
|
||||
pagesQueued: number;
|
||||
taskIds: number[];
|
||||
celeryTaskId?: string;
|
||||
};
|
||||
setField: <K extends keyof BuilderFormData>(key: K, value: BuilderFormData[K]) => void;
|
||||
updateStyle: (partial: Partial<StylePreferences>) => void;
|
||||
addObjective: (value: string) => void;
|
||||
@@ -45,6 +52,10 @@ interface BuilderState {
|
||||
reset: () => void;
|
||||
submitWizard: () => Promise<void>;
|
||||
refreshPages: (blueprintId: number) => Promise<void>;
|
||||
togglePageSelection: (pageId: number) => void;
|
||||
selectAllPages: () => void;
|
||||
clearPageSelection: () => void;
|
||||
generateAllPages: (blueprintId: number, force?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
@@ -52,6 +63,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
currentStep: 0,
|
||||
isSubmitting: false,
|
||||
pages: [],
|
||||
selectedPageIds: [],
|
||||
isGenerating: false,
|
||||
|
||||
setField: (key, value) =>
|
||||
set((state) => ({
|
||||
@@ -151,6 +164,54 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
set({ error: error instanceof Error ? error.message : 'Unable to load pages' });
|
||||
}
|
||||
},
|
||||
|
||||
togglePageSelection: (pageId: number) => {
|
||||
set((state) => {
|
||||
const isSelected = state.selectedPageIds.includes(pageId);
|
||||
return {
|
||||
selectedPageIds: isSelected
|
||||
? state.selectedPageIds.filter((id) => id !== pageId)
|
||||
: [...state.selectedPageIds, pageId],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
selectAllPages: () => {
|
||||
set((state) => ({
|
||||
selectedPageIds: state.pages.map((p) => p.id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearPageSelection: () => {
|
||||
set({ selectedPageIds: [] });
|
||||
},
|
||||
|
||||
generateAllPages: async (blueprintId: number, force = false) => {
|
||||
const { selectedPageIds } = get();
|
||||
set({ isGenerating: true, error: undefined, generationProgress: undefined });
|
||||
|
||||
try {
|
||||
const result = await builderApi.generateAllPages(blueprintId, {
|
||||
pageIds: selectedPageIds.length > 0 ? selectedPageIds : undefined,
|
||||
force,
|
||||
});
|
||||
|
||||
set({
|
||||
generationProgress: {
|
||||
pagesQueued: result.pages_queued,
|
||||
taskIds: result.task_ids,
|
||||
celeryTaskId: result.celery_task_id,
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh pages to update their status
|
||||
await get().refreshPages(blueprintId);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to generate pages' });
|
||||
} finally {
|
||||
set({ isGenerating: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user