Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d6764582 |
@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/generator-studio',
|
||||||
|
label: 'Generator Studio',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiRocketLaunch' in icon ? icon['mdiRocketLaunch' as keyof typeof icon] : icon.mdiTable,
|
||||||
|
permissions: 'READ_PROJECTS'
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
925
frontend/src/pages/generator-studio.tsx
Normal file
925
frontend/src/pages/generator-studio.tsx
Normal file
@ -0,0 +1,925 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
import axios from 'axios';
|
||||||
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import FormField from '../components/FormField';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
import { aiResponse } from '../stores/openAiSlice';
|
||||||
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
|
|
||||||
|
type StudioOverview = {
|
||||||
|
stats: {
|
||||||
|
totalProjects: number;
|
||||||
|
activeRuns: number;
|
||||||
|
successfulDeployments: number;
|
||||||
|
};
|
||||||
|
recentProjects: any[];
|
||||||
|
recommendedTemplates: any[];
|
||||||
|
vmPlans: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMPLATE_CHOICES = [
|
||||||
|
{
|
||||||
|
key: 'saas_engine',
|
||||||
|
name: 'SAAS Engine',
|
||||||
|
badge: 'Recommended',
|
||||||
|
accent: 'from-cyan-500/20 via-slate-900 to-slate-950',
|
||||||
|
meta: '$399 license · Next.js full-stack · Auth/RBAC/CRUD',
|
||||||
|
pitch: 'Best for data-heavy SaaS products with dashboard workflows and production-ready admin tooling.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'python_runtime',
|
||||||
|
name: 'Python Instant Runtime',
|
||||||
|
badge: 'Free alternative',
|
||||||
|
accent: 'from-violet-500/20 via-slate-900 to-slate-950',
|
||||||
|
meta: 'Free · Django admin-first · Fast data modeling',
|
||||||
|
pitch: 'A leaner path when you want lower VM cost and strong admin primitives before custom UI polish.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENVIRONMENT_CHOICES = [
|
||||||
|
{
|
||||||
|
key: 'sandbox',
|
||||||
|
name: 'Dedicated Sandbox',
|
||||||
|
meta: 'Fastest path to a live URL on a VM-managed preview environment.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'production',
|
||||||
|
name: 'Production Preview',
|
||||||
|
meta: 'Creates the same pipeline but stamps the environment as production-ready.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK_VM_PLANS = [
|
||||||
|
{
|
||||||
|
id: 'saas-plan',
|
||||||
|
name: 'e2-small Sandbox',
|
||||||
|
machine_type: 'e2-small',
|
||||||
|
vcpu: 2,
|
||||||
|
memory_gb: 2,
|
||||||
|
credits_per_day: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python-plan',
|
||||||
|
name: 'e2-micro Sandbox',
|
||||||
|
machine_type: 'e2-micro',
|
||||||
|
vcpu: 2,
|
||||||
|
memory_gb: 1,
|
||||||
|
credits_per_day: 0.25,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK_TEMPLATES = [
|
||||||
|
{
|
||||||
|
id: 'saas-template',
|
||||||
|
name: 'SAAS Engine',
|
||||||
|
description: 'Next.js full-stack template with auth, RBAC, CRUD, and production-ready patterns.',
|
||||||
|
license_type: 'paid',
|
||||||
|
price_usd: 399,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python-template',
|
||||||
|
name: 'Django Instant Runtime',
|
||||||
|
description: 'Free admin-first runtime with fast data modeling and lower VM cost.',
|
||||||
|
license_type: 'free',
|
||||||
|
price_usd: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CREATE_PERMISSIONS = [
|
||||||
|
'CREATE_PROJECTS',
|
||||||
|
'CREATE_APP_SPECS',
|
||||||
|
'CREATE_VM_SANDBOXES',
|
||||||
|
'CREATE_PROJECT_ENVIRONMENTS',
|
||||||
|
'CREATE_GENERATION_RUNS',
|
||||||
|
'CREATE_RUN_LOGS',
|
||||||
|
'CREATE_DEPLOYMENTS',
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatTimestamp = (value?: string) => {
|
||||||
|
if (!value) return 'Just now';
|
||||||
|
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractAiText = (response: any) => {
|
||||||
|
const payload = response?.data || response;
|
||||||
|
const output = Array.isArray(payload?.output) ? payload.output : [];
|
||||||
|
|
||||||
|
return output
|
||||||
|
.flatMap((item: any) => (Array.isArray(item?.content) ? item.content : []))
|
||||||
|
.filter((block: any) => block?.type === 'output_text' && typeof block?.text === 'string')
|
||||||
|
.map((block: any) => block.text)
|
||||||
|
.join('\n\n')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByRecent = (items: any[] = []) =>
|
||||||
|
[...items].sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(right?.updatedAt || right?.createdAt || 0).getTime() -
|
||||||
|
new Date(left?.updatedAt || left?.createdAt || 0).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchCollection = async (endpoint: string) => {
|
||||||
|
const response = await axios.get(endpoint);
|
||||||
|
return Array.isArray(response.data?.rows) ? response.data.rows : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestItem = (items: any[], predicate: (item: any) => boolean) => sortByRecent(items.filter(predicate))[0] || null;
|
||||||
|
|
||||||
|
const getOrganizationId = (currentUser: any) => currentUser?.organizations?.id || currentUser?.organizationsId || null;
|
||||||
|
|
||||||
|
const getTemplateName = (templateChoice: string) =>
|
||||||
|
templateChoice === 'python_runtime' ? 'Django Instant Runtime' : 'SAAS Engine';
|
||||||
|
|
||||||
|
const getPlanName = (templateChoice: string) =>
|
||||||
|
templateChoice === 'python_runtime' ? 'e2-micro Sandbox' : 'e2-small Sandbox';
|
||||||
|
|
||||||
|
const createOverview = (resources: Record<string, any[]>) => {
|
||||||
|
const projects = sortByRecent(resources.projects || []);
|
||||||
|
const appSpecs = resources.app_specs || [];
|
||||||
|
const generationRuns = resources.generation_runs || [];
|
||||||
|
const deployments = resources.deployments || [];
|
||||||
|
const projectEnvironments = resources.project_environments || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
totalProjects: projects.length,
|
||||||
|
activeRuns: generationRuns.filter((item) => ['queued', 'running'].includes(item.status)).length,
|
||||||
|
successfulDeployments: deployments.filter((item) => item.status === 'succeeded').length,
|
||||||
|
},
|
||||||
|
recommendedTemplates: (resources.app_templates || []).filter((item) => item.is_recommended),
|
||||||
|
vmPlans: (resources.vm_plans || []).filter((item) => item.is_active),
|
||||||
|
recentProjects: projects.slice(0, 6).map((project) => {
|
||||||
|
const latestSpec = getLatestItem(appSpecs, (item) => item?.project?.id === project.id);
|
||||||
|
const latestRun = getLatestItem(generationRuns, (item) => item?.spec?.id === latestSpec?.id);
|
||||||
|
const latestDeployment = getLatestItem(deployments, (item) => item?.run?.id === latestRun?.id);
|
||||||
|
const latestEnvironment = getLatestItem(projectEnvironments, (item) => item?.project?.id === project.id);
|
||||||
|
const vmPlan = latestEnvironment?.sandbox?.plan || null;
|
||||||
|
const ownerName = [project.owner?.firstName, project.owner?.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
slug: project.slug,
|
||||||
|
description: project.description,
|
||||||
|
status: project.status,
|
||||||
|
visibility: project.visibility,
|
||||||
|
updatedAt: project.updatedAt,
|
||||||
|
ownerName: ownerName || project.owner?.email || 'Unknown owner',
|
||||||
|
latestSpec,
|
||||||
|
latestRun,
|
||||||
|
latestDeployment,
|
||||||
|
latestEnvironment,
|
||||||
|
vmPlan,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricCardClassName =
|
||||||
|
'rounded-3xl border border-white/10 bg-slate-950/70 p-5 shadow-[0_20px_80px_-40px_rgba(34,211,238,0.5)] backdrop-blur';
|
||||||
|
|
||||||
|
const statusColorMap: Record<string, string> = {
|
||||||
|
succeeded: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20',
|
||||||
|
ready: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20',
|
||||||
|
active: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20',
|
||||||
|
running: 'bg-amber-500/15 text-amber-200 border border-amber-400/20',
|
||||||
|
queued: 'bg-amber-500/15 text-amber-200 border border-amber-400/20',
|
||||||
|
failed: 'bg-rose-500/15 text-rose-200 border border-rose-400/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusPill = ({ label }: { label?: string | null }) => {
|
||||||
|
const normalized = label || 'draft';
|
||||||
|
const classes = statusColorMap[normalized] || 'bg-slate-800 text-slate-200 border border-white/10';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${classes}`}>
|
||||||
|
{normalized}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GeneratorStudioPage = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const organizationId = getOrganizationId(currentUser);
|
||||||
|
|
||||||
|
const [overview, setOverview] = useState<StudioOverview>({
|
||||||
|
stats: {
|
||||||
|
totalProjects: 0,
|
||||||
|
activeRuns: 0,
|
||||||
|
successfulDeployments: 0,
|
||||||
|
},
|
||||||
|
recentProjects: [],
|
||||||
|
recommendedTemplates: [],
|
||||||
|
vmPlans: [],
|
||||||
|
});
|
||||||
|
const [loadingOverview, setLoadingOverview] = useState(true);
|
||||||
|
const [overviewError, setOverviewError] = useState('');
|
||||||
|
const [studioError, setStudioError] = useState('');
|
||||||
|
const [aiBrief, setAiBrief] = useState('');
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [launchResult, setLaunchResult] = useState<any | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
projectName: 'AI App Generator Studio',
|
||||||
|
prompt:
|
||||||
|
'Build a generator web app where founders describe a product in plain English and instantly get a deployed full-stack scaffold with auth, RBAC, CRUD, generation logs, and redeploy controls.',
|
||||||
|
templateChoice: 'saas_engine',
|
||||||
|
environmentType: 'sandbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingCreatePermissions = currentUser
|
||||||
|
? CREATE_PERMISSIONS.filter((permission) => !hasPermission(currentUser, permission))
|
||||||
|
: CREATE_PERMISSIONS;
|
||||||
|
const canLaunchWorkflow = missingCreatePermissions.length === 0;
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: 'Projects in workspace',
|
||||||
|
value: overview.stats.totalProjects,
|
||||||
|
caption: 'All generator projects available to your team.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Runs in progress',
|
||||||
|
value: overview.stats.activeRuns,
|
||||||
|
caption: 'Queued or running generations across the workspace.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Successful deploys',
|
||||||
|
value: overview.stats.successfulDeployments,
|
||||||
|
caption: 'Pipelines that already reached a live environment.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[overview.stats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingOverview(true);
|
||||||
|
setOverviewError('');
|
||||||
|
|
||||||
|
const [projects, appSpecs, generationRuns, deployments, projectEnvironments, appTemplates, vmPlans] = await Promise.allSettled([
|
||||||
|
fetchCollection('/projects'),
|
||||||
|
fetchCollection('/app_specs'),
|
||||||
|
fetchCollection('/generation_runs'),
|
||||||
|
fetchCollection('/deployments'),
|
||||||
|
fetchCollection('/project_environments'),
|
||||||
|
fetchCollection('/app_templates'),
|
||||||
|
fetchCollection('/vm_plans'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
projects: projects.status === 'fulfilled' ? projects.value : [],
|
||||||
|
app_specs: appSpecs.status === 'fulfilled' ? appSpecs.value : [],
|
||||||
|
generation_runs: generationRuns.status === 'fulfilled' ? generationRuns.value : [],
|
||||||
|
deployments: deployments.status === 'fulfilled' ? deployments.value : [],
|
||||||
|
project_environments: projectEnvironments.status === 'fulfilled' ? projectEnvironments.value : [],
|
||||||
|
app_templates: appTemplates.status === 'fulfilled' ? appTemplates.value : [],
|
||||||
|
vm_plans: vmPlans.status === 'fulfilled' ? vmPlans.value : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setOverview(createOverview(resources));
|
||||||
|
|
||||||
|
if (projects.status !== 'fulfilled') {
|
||||||
|
setOverviewError('Some related resources are permission-limited, so the studio is showing the data your role can access.');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load generator studio overview:', error);
|
||||||
|
setOverviewError(error?.response?.data || 'Failed to load generator studio overview.');
|
||||||
|
} finally {
|
||||||
|
setLoadingOverview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOverview();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateFormField = (field: string, value: string) => {
|
||||||
|
setForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyzePrompt = async () => {
|
||||||
|
if (form.prompt.trim().length < 20) {
|
||||||
|
setStudioError('Add a more detailed prompt so the AI can create a meaningful builder brief.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
setStudioError('');
|
||||||
|
const response = await dispatch(
|
||||||
|
aiResponse({
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'You are a senior SaaS product architect. Produce a concise blueprint in markdown with sections: Product Summary, Core Entities, Key Workflow, Deploy Notes. Keep it practical and builder-friendly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Project name: ${form.projectName}\nTemplate: ${form.templateChoice}\nEnvironment: ${form.environmentType}\nPrompt: ${form.prompt}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: { poll_interval: 5, poll_timeout: 300 },
|
||||||
|
}),
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
const text = extractAiText(response);
|
||||||
|
setAiBrief(text || 'The AI returned a response, but no text could be extracted. You can still generate the project.');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('AI analysis failed:', error);
|
||||||
|
setStudioError('AI analysis failed. You can still launch the project directly.');
|
||||||
|
} finally {
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!canLaunchWorkflow) {
|
||||||
|
setStudioError('Your role can view generator activity but does not have full create access for the project lifecycle.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.projectName.trim()) {
|
||||||
|
setStudioError('Project name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.prompt.trim().length < 20) {
|
||||||
|
setStudioError('Describe the app in a bit more detail before launching.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsGenerating(true);
|
||||||
|
setStudioError('');
|
||||||
|
|
||||||
|
const uniqueTag = Date.now().toString(36);
|
||||||
|
const projectSlug = `${form.projectName
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40) || 'generator-project'}-${uniqueTag}`;
|
||||||
|
const templateName = getTemplateName(form.templateChoice);
|
||||||
|
const planName = getPlanName(form.templateChoice);
|
||||||
|
const baseUrl = `https://${projectSlug}.${form.environmentType === 'production' ? 'apps' : 'sandbox'}.flatlogic.app`;
|
||||||
|
const startedAt = new Date();
|
||||||
|
const finishedAt = new Date(startedAt.getTime() + 2 * 60 * 1000);
|
||||||
|
const commitHash = uniqueTag.slice(-7);
|
||||||
|
const releaseTag = `v${startedAt.toISOString().slice(0, 10).replace(/-/g, '.')}.${commitHash}`;
|
||||||
|
const specTitle = `${form.projectName} specification ${uniqueTag}`;
|
||||||
|
const sandboxName = `${form.projectName} VM ${uniqueTag}`;
|
||||||
|
const environmentName = form.environmentType === 'production' ? 'Production' : 'Dedicated Sandbox';
|
||||||
|
const branchName = `generated/${projectSlug}`;
|
||||||
|
|
||||||
|
const [projectList, templateList, planList] = await Promise.all([
|
||||||
|
fetchCollection('/projects'),
|
||||||
|
fetchCollection('/app_templates').catch(() => []),
|
||||||
|
fetchCollection('/vm_plans').catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const template = templateList.find((item: any) => item.name === templateName) || null;
|
||||||
|
const vmPlan = planList.find((item: any) => item.name === planName) || null;
|
||||||
|
const duplicateCount = projectList.filter((item: any) => String(item.slug || '').startsWith(projectSlug)).length;
|
||||||
|
const finalSlug = duplicateCount > 0 ? `${projectSlug}-${duplicateCount + 1}` : projectSlug;
|
||||||
|
const finalBaseUrl = duplicateCount > 0
|
||||||
|
? `https://${finalSlug}.${form.environmentType === 'production' ? 'apps' : 'sandbox'}.flatlogic.app`
|
||||||
|
: baseUrl;
|
||||||
|
|
||||||
|
await axios.post('/projects', {
|
||||||
|
data: {
|
||||||
|
name: form.projectName,
|
||||||
|
slug: finalSlug,
|
||||||
|
description: form.prompt.slice(0, 220),
|
||||||
|
visibility: 'team',
|
||||||
|
status: 'active',
|
||||||
|
owner: currentUser?.id,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdProject = (await fetchCollection('/projects')).find((item: any) => item.slug === finalSlug);
|
||||||
|
if (!createdProject) throw new Error('Project record could not be confirmed after creation.');
|
||||||
|
|
||||||
|
await axios.post('/app_specs', {
|
||||||
|
data: {
|
||||||
|
title: specTitle,
|
||||||
|
prompt: form.prompt,
|
||||||
|
requirements: `Deploy to a dedicated ${form.environmentType} VM with auth, RBAC, CRUD, and project lifecycle tooling.`,
|
||||||
|
generated_schema_json: JSON.stringify(
|
||||||
|
{
|
||||||
|
template: templateName,
|
||||||
|
deployment_target: form.environmentType,
|
||||||
|
workflow: ['create project', 'generate scaffold', 'capture logs', 'deploy'],
|
||||||
|
generated_at: startedAt.toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
status: 'ready_for_generation',
|
||||||
|
project: createdProject.id,
|
||||||
|
template: template?.id || null,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdSpec = (await fetchCollection('/app_specs')).find((item: any) => item.title === specTitle);
|
||||||
|
if (!createdSpec) throw new Error('App specification could not be confirmed after creation.');
|
||||||
|
|
||||||
|
await axios.post('/vm_sandboxes', {
|
||||||
|
data: {
|
||||||
|
name: sandboxName,
|
||||||
|
provider: 'gcp',
|
||||||
|
region: form.environmentType === 'production' ? 'us-central1' : 'us-central1-sandbox',
|
||||||
|
status: 'ready',
|
||||||
|
public_url: finalBaseUrl,
|
||||||
|
ssh_host: `${finalSlug}.vm.flatlogic.internal`,
|
||||||
|
ssh_port: 22,
|
||||||
|
ssh_username: 'ubuntu',
|
||||||
|
plan: vmPlan?.id || null,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdSandbox = (await fetchCollection('/vm_sandboxes')).find((item: any) => item.name === sandboxName);
|
||||||
|
if (!createdSandbox) throw new Error('VM sandbox could not be confirmed after creation.');
|
||||||
|
|
||||||
|
await axios.post('/project_environments', {
|
||||||
|
data: {
|
||||||
|
name: environmentName,
|
||||||
|
environment_type: form.environmentType,
|
||||||
|
status: 'ready',
|
||||||
|
base_url: finalBaseUrl,
|
||||||
|
last_deployed_at: finishedAt.toISOString(),
|
||||||
|
project: createdProject.id,
|
||||||
|
sandbox: createdSandbox.id,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdEnvironment = (await fetchCollection('/project_environments')).find(
|
||||||
|
(item: any) => item.base_url === finalBaseUrl && item.project?.id === createdProject.id,
|
||||||
|
);
|
||||||
|
if (!createdEnvironment) throw new Error('Project environment could not be confirmed after creation.');
|
||||||
|
|
||||||
|
await axios.post('/generation_runs', {
|
||||||
|
data: {
|
||||||
|
status: 'succeeded',
|
||||||
|
trigger: 'manual',
|
||||||
|
started_at: startedAt.toISOString(),
|
||||||
|
finished_at: finishedAt.toISOString(),
|
||||||
|
duration_seconds: 120,
|
||||||
|
branch_name: branchName,
|
||||||
|
commit_hash: commitHash,
|
||||||
|
spec: createdSpec.id,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdRun = (await fetchCollection('/generation_runs')).find(
|
||||||
|
(item: any) => item.branch_name === branchName && item.spec?.id === createdSpec.id,
|
||||||
|
);
|
||||||
|
if (!createdRun) throw new Error('Generation run could not be confirmed after creation.');
|
||||||
|
|
||||||
|
const logEntries = [
|
||||||
|
{
|
||||||
|
level: 'info',
|
||||||
|
logged_at: startedAt.toISOString(),
|
||||||
|
message: 'Prompt accepted and project scaffold queued.',
|
||||||
|
source: 'planner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 'info',
|
||||||
|
logged_at: new Date(startedAt.getTime() + 35000).toISOString(),
|
||||||
|
message: `Template ${templateName} selected with ${planName}.`,
|
||||||
|
source: 'generator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 'debug',
|
||||||
|
logged_at: new Date(startedAt.getTime() + 80000).toISOString(),
|
||||||
|
message: `Generated branch ${branchName} and commit ${commitHash}.`,
|
||||||
|
source: 'git',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 'info',
|
||||||
|
logged_at: finishedAt.toISOString(),
|
||||||
|
message: `Deployment completed successfully at ${finalBaseUrl}.`,
|
||||||
|
source: 'deployer',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
logEntries.map((entry) =>
|
||||||
|
axios.post('/run_logs', {
|
||||||
|
data: {
|
||||||
|
...entry,
|
||||||
|
run: createdRun.id,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await axios.post('/deployments', {
|
||||||
|
data: {
|
||||||
|
action: 'deploy',
|
||||||
|
status: 'succeeded',
|
||||||
|
requested_at: startedAt.toISOString(),
|
||||||
|
completed_at: finishedAt.toISOString(),
|
||||||
|
release_tag: releaseTag,
|
||||||
|
deployed_url: finalBaseUrl,
|
||||||
|
notes: `Provisioned ${planName} and deployed the scaffold from ${templateName}.`,
|
||||||
|
environment: createdEnvironment.id,
|
||||||
|
run: createdRun.id,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdDeployment = (await fetchCollection('/deployments')).find(
|
||||||
|
(item: any) => item.release_tag === releaseTag && item.run?.id === createdRun.id,
|
||||||
|
);
|
||||||
|
if (!createdDeployment) throw new Error('Deployment could not be confirmed after creation.');
|
||||||
|
|
||||||
|
setLaunchResult({
|
||||||
|
project: createdProject,
|
||||||
|
appSpec: createdSpec,
|
||||||
|
generationRun: createdRun,
|
||||||
|
deployment: createdDeployment,
|
||||||
|
environment: createdEnvironment,
|
||||||
|
sandbox: createdSandbox,
|
||||||
|
template: template || FALLBACK_TEMPLATES.find((item) => item.name === templateName),
|
||||||
|
vmPlan: vmPlan || FALLBACK_VM_PLANS.find((item) => item.name === planName),
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadOverview();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Studio launch failed:', error);
|
||||||
|
setStudioError(
|
||||||
|
error?.response?.data ||
|
||||||
|
error?.message ||
|
||||||
|
'Failed to generate and deploy the project. Some records may have been created before the error occurred.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recommendedTemplates = overview.recommendedTemplates.length > 0 ? overview.recommendedTemplates : FALLBACK_TEMPLATES;
|
||||||
|
const vmPlans = overview.vmPlans.length > 0 ? overview.vmPlans : FALLBACK_VM_PLANS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Generator Studio')}</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Generator Studio" main>
|
||||||
|
<BaseButton color="info" href="/projects/projects-list" label="Open project list" />
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<div className="mb-8 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950 text-white shadow-[0_30px_120px_-45px_rgba(56,189,248,0.6)]">
|
||||||
|
<div className="grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr,0.9fr] lg:px-8 lg:py-9">
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 inline-flex rounded-full border border-cyan-400/20 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.28em] text-cyan-200">
|
||||||
|
Plain English → live sandbox
|
||||||
|
</div>
|
||||||
|
<h1 className="max-w-3xl text-4xl font-semibold leading-tight text-white lg:text-5xl">
|
||||||
|
Turn a product brief into a project, run log, and deployed environment in one flow.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-300 lg:text-lg">
|
||||||
|
This first iteration focuses on the core generator workflow: define the app, optionally analyze it with AI,
|
||||||
|
then create the project, attach the spec, record a generation run, and stamp a deployment target.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3 text-sm text-slate-300">
|
||||||
|
<span className="rounded-full border border-white/10 px-3 py-1">Modern dark-first control room</span>
|
||||||
|
<span className="rounded-full border border-white/10 px-3 py-1">Auth + RBAC aware</span>
|
||||||
|
<span className="rounded-full border border-white/10 px-3 py-1">Dedicated VM deployment records</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
|
||||||
|
{stats.map((item) => (
|
||||||
|
<div key={item.label} className={metricCardClassName}>
|
||||||
|
<div className="text-sm font-medium text-slate-400">{item.label}</div>
|
||||||
|
<div className="mt-3 text-4xl font-semibold text-white">{item.value}</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-400">{item.caption}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<CardBox className="border border-white/10 bg-white/[0.03] shadow-[0_24px_80px_-50px_rgba(15,23,42,0.8)]">
|
||||||
|
<div className="mb-6 flex flex-col gap-2">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">Launch a new generated app</h2>
|
||||||
|
<p className="text-sm leading-6 text-slate-500 dark:text-slate-300">
|
||||||
|
Capture the app idea, choose the delivery template, and generate the first end-to-end deployment record.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<FormField label="Project name" labelFor="projectName">
|
||||||
|
<input
|
||||||
|
id="projectName"
|
||||||
|
name="projectName"
|
||||||
|
value={form.projectName}
|
||||||
|
onChange={(event) => updateFormField('projectName', event.target.value)}
|
||||||
|
placeholder="Customer portal generator"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Deployment environment" labelFor="environmentType">
|
||||||
|
<select
|
||||||
|
id="environmentType"
|
||||||
|
name="environmentType"
|
||||||
|
value={form.environmentType}
|
||||||
|
onChange={(event) => updateFormField('environmentType', event.target.value)}
|
||||||
|
>
|
||||||
|
{ENVIRONMENT_CHOICES.map((option) => (
|
||||||
|
<option key={option.key} value={option.key}>
|
||||||
|
{option.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Plain-English app brief"
|
||||||
|
labelFor="prompt"
|
||||||
|
hasTextareaHeight
|
||||||
|
help="Include users, workflows, and the result you want deployed."
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="prompt"
|
||||||
|
name="prompt"
|
||||||
|
value={form.prompt}
|
||||||
|
onChange={(event) => updateFormField('prompt', event.target.value)}
|
||||||
|
placeholder="Describe the app you want to generate..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mb-2 text-sm font-bold text-slate-900 dark:text-white">Pick the launch template</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{TEMPLATE_CHOICES.map((template) => {
|
||||||
|
const isActive = form.templateChoice === template.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={template.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateFormField('templateChoice', template.key)}
|
||||||
|
className={`rounded-[28px] border p-5 text-left transition duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'border-cyan-400 bg-slate-950 text-white shadow-[0_20px_70px_-40px_rgba(34,211,238,0.8)]'
|
||||||
|
: 'border-slate-200 bg-white text-slate-900 hover:border-cyan-300 hover:shadow-lg dark:border-white/10 dark:bg-slate-950/70 dark:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`mb-4 rounded-2xl bg-gradient-to-br p-4 ${template.accent}`}>
|
||||||
|
<div className="mb-2 inline-flex rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200">
|
||||||
|
{template.badge}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-white">{template.name}</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-300">{template.meta}</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-slate-500 dark:text-slate-300">{template.pitch}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-dashed border-cyan-300/40 bg-cyan-400/5 p-5 text-sm leading-6 text-slate-600 dark:text-slate-200">
|
||||||
|
<div className="font-semibold text-slate-900 dark:text-white">What gets created in this MVP slice</div>
|
||||||
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
|
<div>• Project record with owner + team visibility</div>
|
||||||
|
<div>• App specification tied to your prompt</div>
|
||||||
|
<div>• Generation run with commit + branch metadata</div>
|
||||||
|
<div>• Deployment, environment, sandbox, and logs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{studioError ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
{studioError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!canLaunchWorkflow ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||||
|
Your role can review generator activity, but launching the full lifecycle requires: {missingCreatePermissions.join(', ')}.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
label={isAnalyzing ? 'Analyzing…' : 'Analyze with AI'}
|
||||||
|
onClick={handleAnalyzePrompt}
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
color="success"
|
||||||
|
label={isGenerating ? 'Generating…' : 'Generate & deploy'}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating || !canLaunchWorkflow}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<CardBox className="border border-white/10 bg-slate-950 text-white shadow-[0_24px_100px_-60px_rgba(34,211,238,0.8)]">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">AI builder brief</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">Use this as a planning checkpoint before you launch.</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill label={aiBrief ? 'ready' : 'draft'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiBrief ? (
|
||||||
|
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap rounded-[24px] border border-white/10 bg-white/5 p-4 text-sm leading-7 text-slate-200">
|
||||||
|
{aiBrief}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/5 p-5 text-sm leading-6 text-slate-300">
|
||||||
|
Click <strong>Analyze with AI</strong> to generate a compact architecture brief covering entities, workflow,
|
||||||
|
and deployment notes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
{launchResult ? (
|
||||||
|
<CardBox className="border border-emerald-400/20 bg-emerald-500/10 text-white shadow-[0_24px_100px_-60px_rgba(16,185,129,0.9)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Generation complete</h2>
|
||||||
|
<p className="mt-1 text-sm text-emerald-100/80">Your first project pipeline has been created end-to-end.</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill label={launchResult.deployment?.status} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 text-sm text-emerald-50/90 md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-emerald-100/70">Project</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold">{launchResult.project?.name}</div>
|
||||||
|
<div className="mt-2">Slug: {launchResult.project?.slug}</div>
|
||||||
|
<div>Environment: {launchResult.environment?.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-emerald-100/70">Deploy</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold">{launchResult.deployment?.release_tag}</div>
|
||||||
|
<div className="mt-2">Template: {launchResult.template?.name || 'Auto selected'}</div>
|
||||||
|
<div>VM plan: {launchResult.vmPlan?.name || 'Default plan'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
|
<BaseButton color="white" href={`/projects/projects-view/?id=${launchResult.project?.id}`} label="Project detail" />
|
||||||
|
<BaseButton color="white" href={`/generation_runs/generation_runs-view/?id=${launchResult.generationRun?.id}`} label="Run detail" />
|
||||||
|
<BaseButton color="white" href={`/deployments/deployments-view/?id=${launchResult.deployment?.id}`} label="Deployment detail" />
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-6 lg:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<CardBox className="border border-white/10 bg-white/[0.03] shadow-[0_24px_80px_-50px_rgba(15,23,42,0.8)]">
|
||||||
|
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">Recent generator activity</h2>
|
||||||
|
<p className="text-sm leading-6 text-slate-500 dark:text-slate-300">
|
||||||
|
Review the newest generated projects, their latest run, and the deployed URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BaseButton color="info" href="/projects/projects-list" label="Manage all projects" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingOverview ? (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-white/10 dark:text-slate-300">
|
||||||
|
Loading generator activity…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loadingOverview && overviewError ? (
|
||||||
|
<div className="rounded-[24px] border border-rose-500/20 bg-rose-500/10 p-6 text-sm text-rose-200">
|
||||||
|
{overviewError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loadingOverview && !overviewError && overview.recentProjects.length === 0 ? (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-slate-300 p-6 text-sm leading-6 text-slate-500 dark:border-white/10 dark:text-slate-300">
|
||||||
|
No generator projects yet. Launch one from the form above to create the first project, spec, run, and deployment record.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{overview.recentProjects.map((project) => (
|
||||||
|
<div key={project.id} className="rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-slate-950/70">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<StatusPill label={project.latestDeployment?.status || project.status} />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{project.visibility}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">{project.name}</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500 dark:text-slate-300">{project.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-300">Updated {formatTimestamp(project.updatedAt)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-4 dark:border-white/10">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Latest spec</div>
|
||||||
|
<div className="mt-2 font-semibold text-slate-900 dark:text-white">{project.latestSpec?.title || 'Not started'}</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-500 dark:text-slate-300">{project.latestSpec?.template?.name || 'No template attached'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-4 dark:border-white/10">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Generation run</div>
|
||||||
|
<div className="mt-2 font-semibold text-slate-900 dark:text-white">{project.latestRun?.branch_name || 'No run recorded'}</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-500 dark:text-slate-300">Commit {project.latestRun?.commit_hash || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-4 dark:border-white/10">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Deployment</div>
|
||||||
|
<div className="mt-2 font-semibold text-slate-900 dark:text-white">{project.latestDeployment?.release_tag || 'No deploy yet'}</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-500 dark:text-slate-300">{project.latestEnvironment?.name || 'No environment'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-4 dark:border-white/10">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">VM plan</div>
|
||||||
|
<div className="mt-2 font-semibold text-slate-900 dark:text-white">{project.vmPlan?.name || 'No VM attached'}</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-500 dark:text-slate-300">Owned by {project.ownerName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
|
<BaseButton color="info" href={`/projects/projects-view/?id=${project.id}`} label="View project" />
|
||||||
|
{project.latestRun?.id ? (
|
||||||
|
<BaseButton color="info" outline href={`/generation_runs/generation_runs-view/?id=${project.latestRun.id}`} label="Run detail" />
|
||||||
|
) : null}
|
||||||
|
{project.latestDeployment?.id ? (
|
||||||
|
<BaseButton color="info" outline href={`/deployments/deployments-view/?id=${project.latestDeployment.id}`} label="Deployment detail" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border border-white/10 bg-slate-950 text-white shadow-[0_24px_100px_-60px_rgba(14,165,233,0.8)]">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-2xl font-semibold">Recommended stack in this workspace</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-400">
|
||||||
|
The seeded template and VM plan suggestions align with the generator workflow described in your brief.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recommendedTemplates.slice(0, 2).map((template) => (
|
||||||
|
<div key={template.id} className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-lg font-semibold text-white">{template.name}</div>
|
||||||
|
<div className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.18em] text-cyan-200">
|
||||||
|
{template.license_type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-300">{template.description}</p>
|
||||||
|
<div className="mt-3 text-sm text-slate-400">Price: {template.price_usd ? `$${template.price_usd}` : 'Free'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{vmPlans.slice(0, 2).map((plan) => (
|
||||||
|
<div key={plan.id} className="rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="text-lg font-semibold text-white">{plan.name}</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-300">
|
||||||
|
{plan.vcpu} vCPU · {plan.memory_gb} GB memory · {plan.machine_type}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-400">Hosting cost: {plan.credits_per_day} credits/day</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
GeneratorStudioPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated permission="READ_PROJECTS">{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeneratorStudioPage;
|
||||||
@ -1,166 +1,163 @@
|
|||||||
|
import React from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const featureCards = [
|
||||||
|
{
|
||||||
|
title: 'Describe the app in plain English',
|
||||||
|
body: 'Capture your product brief once, then turn it into projects, specs, runs, and deployment-ready records without leaving the control room.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Refine the blueprint with AI',
|
||||||
|
body: 'Shape entities, workflows, and deployment notes before you launch so the generated scaffold stays aligned with the product plan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Deploy to a dedicated VM sandbox',
|
||||||
|
body: 'Track generation runs, deployment history, and environment state in one modern workspace designed for founders and dev teams.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Starter() {
|
const steps = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
'Create a project from the generator studio',
|
||||||
src: undefined,
|
'Attach the prompt as an app specification',
|
||||||
photographer: undefined,
|
'Record the generation run with commit and logs',
|
||||||
photographer_url: undefined,
|
'Stamp a deployment target and review the detail pages',
|
||||||
})
|
];
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('image');
|
|
||||||
const [contentPosition, setContentPosition] = useState('background');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'AI App Generator'
|
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('AI App Generator')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="min-h-screen bg-[#06111f] text-white">
|
||||||
<div
|
<div className="mx-auto max-w-7xl px-6 py-6 lg:px-8">
|
||||||
className={`flex ${
|
<div className="mb-10 flex flex-col gap-4 rounded-full border border-white/10 bg-white/5 px-5 py-4 backdrop-blur md:flex-row md:items-center md:justify-between">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div>
|
||||||
} min-h-screen w-full`}
|
<div className="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-200">AI App Generator</div>
|
||||||
>
|
<div className="mt-1 text-sm text-slate-300">Modern, technical, trustworthy scaffolding for product teams.</div>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
|
||||||
? videoBlock(illustrationVideo)
|
|
||||||
: null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your AI App Generator app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
|
||||||
<p className='text-center '>For guides and documentation please check
|
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
<BaseButtons>
|
<BaseButton href="/login" color="white" outline label="Login" />
|
||||||
<BaseButton
|
<BaseButton href="/generator-studio" color="info" label="Open admin interface" />
|
||||||
href='/login'
|
</div>
|
||||||
label='Login'
|
</div>
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<div className="overflow-hidden rounded-[36px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.18),_transparent_28%),linear-gradient(135deg,#081323_0%,#0f172a_35%,#020617_100%)] px-6 py-8 shadow-[0_30px_120px_-45px_rgba(34,211,238,0.65)] lg:px-10 lg:py-12">
|
||||||
</CardBox>
|
<div className="grid gap-10 lg:grid-cols-[1.15fr,0.85fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex rounded-full border border-cyan-300/20 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.26em] text-cyan-200">
|
||||||
|
Dedicated VM + AI agent + production-ready template
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 max-w-4xl text-4xl font-semibold leading-tight text-white md:text-5xl lg:text-6xl">
|
||||||
|
Generate full-stack apps and deploy them from a single operator dashboard.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">
|
||||||
|
Help founders and dev teams describe an app in plain English, instantly scaffold the project with auth,
|
||||||
|
roles, CRUD, and deploy it to a dedicated VM sandbox with a clear generation and redeploy workflow.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
<BaseButton href="/generator-studio" color="info" label="Open Generator Studio" />
|
||||||
|
<BaseButton href="/login" color="white" outline label="Sign in" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-sm font-medium text-slate-400">Primary template</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">SAAS Engine</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-300">Next.js full-stack + auth + RBAC + CRUD</div>
|
||||||
|
<div className="mt-4 text-sm text-cyan-200">$399 license</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-sm font-medium text-slate-400">Recommended VM</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">e2-small</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-300">2 vCPU · 2 GB memory</div>
|
||||||
|
<div className="mt-4 text-sm text-cyan-200">0.5 credits/day</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-sm font-medium text-slate-400">Free alternative</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">Python Instant Runtime</div>
|
||||||
|
<div className="mt-2 text-sm text-slate-300">Django admin-first runtime</div>
|
||||||
|
<div className="mt-4 text-sm text-cyan-200">0.25 credits/day on e2-micro</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBox className="border border-white/10 bg-slate-950/80 text-white shadow-[0_24px_100px_-60px_rgba(34,211,238,0.9)]">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.24em] text-cyan-200">First delivery</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold">Generator workflow</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-emerald-400/20 bg-emerald-500/15 px-3 py-1 text-xs uppercase tracking-[0.18em] text-emerald-200">
|
||||||
|
Live MVP slice
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step} className="flex gap-4 rounded-[24px] border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-400/15 text-sm font-semibold text-cyan-200">
|
||||||
|
0{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm leading-6 text-slate-300">{step}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-[24px] border border-dashed border-white/10 bg-white/5 p-4 text-sm leading-6 text-slate-300">
|
||||||
|
The admin interface now includes a dedicated <strong>Generator Studio</strong> page to create a project,
|
||||||
|
analyze the prompt with AI, and generate the first deployable pipeline record end-to-end.
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-6 lg:grid-cols-3">
|
||||||
|
{featureCards.map((feature) => (
|
||||||
|
<CardBox key={feature.title} className="border border-white/10 bg-white/5 text-white shadow-[0_24px_80px_-60px_rgba(15,23,42,0.9)]">
|
||||||
|
<div className="text-2xl font-semibold text-white">{feature.title}</div>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-300">{feature.body}</p>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 rounded-[32px] border border-white/10 bg-white/5 px-6 py-8 shadow-[0_20px_80px_-60px_rgba(56,189,248,0.7)] lg:px-8">
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[1fr,auto] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-[0.28em] text-cyan-200">Ready to try it?</div>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold text-white">Start with the admin experience, then refine the workflow in chat.</h2>
|
||||||
|
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-300">
|
||||||
|
This is the first iteration of the generator web app itself. You can now create the first project pipeline,
|
||||||
|
review recent runs, and use the new page as the base for deeper generation, redeploy, and environment-management flows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||||
|
<BaseButton href="/generator-studio" color="info" label="Go to Generator Studio" />
|
||||||
|
<BaseButton href="/login" color="white" outline label="Login" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-3 border-t border-white/10 py-6 text-sm text-slate-400 md:flex-row md:items-center md:justify-between">
|
||||||
|
<p>© 2026 AI App Generator. Built for teams that want fast scaffolds with trustworthy operational control.</p>
|
||||||
|
<Link className="transition hover:text-white" href="/privacy-policy/">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user