projects
This commit is contained in:
@@ -82,7 +82,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="p-1 rounded text-duck-dark/30 hover:text-duck-dark/60 transition-colors cursor-pointer"
|
||||
className="p-1 rounded text-duck-dark dark:text-white opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
{state === 'loading' && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{state === 'playing' && <Square className="h-3.5 w-3.5" />}
|
||||
@@ -133,9 +133,9 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
{injectImages(assistantText)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-0.5">
|
||||
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||
<div className="flex justify-end -mb-1 -mr-1">
|
||||
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,13 @@ export type PreviewContextValue = {
|
||||
port: number | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
stopped: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
iframeKey: number;
|
||||
projects: ProjectDefinition[];
|
||||
startServer: (slug: string) => void;
|
||||
stopServer: () => void;
|
||||
restartServer: () => void;
|
||||
refresh: () => void;
|
||||
setSelectedSlug: (slug: string | null) => void;
|
||||
clearError: () => void;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Globe, RefreshCw, Square } from 'lucide-react';
|
||||
import { Globe, RefreshCw, Square, Play } from 'lucide-react';
|
||||
import { usePreview } from './PreviewContext';
|
||||
|
||||
export const PreviewHeader = () => {
|
||||
const { slug, url, port, isSuperAdmin, refresh, stopServer } = usePreview();
|
||||
const { slug, url, port, stopped, isSuperAdmin, refresh, stopServer, restartServer } = usePreview();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -32,6 +32,16 @@ export const PreviewHeader = () => {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!url && stopped && slug && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={restartServer}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||
title="Start server"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [stopped, setStopped] = useState(false);
|
||||
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const cwdSlug = extractSlug(cwd);
|
||||
@@ -31,6 +32,7 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
const startServer = useCallback(async (targetSlug: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStopped(false);
|
||||
try {
|
||||
const res = await client.post<DevServerResponse>('/dev-server/start', { slug: targetSlug });
|
||||
setUrl(res.url);
|
||||
@@ -52,8 +54,14 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
}
|
||||
setUrl(null);
|
||||
setPort(null);
|
||||
setStopped(true);
|
||||
}, [slug, client]);
|
||||
|
||||
const restartServer = useCallback(() => {
|
||||
if (!slug) return;
|
||||
startServer(slug);
|
||||
}, [slug, startServer]);
|
||||
|
||||
const refresh = useCallback(() => setIframeKey((k) => k + 1), []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
@@ -108,8 +116,8 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => {
|
||||
return (
|
||||
<PreviewContext
|
||||
value={{
|
||||
slug, cwdSlug, url, port, loading, error, isSuperAdmin, iframeKey, projects,
|
||||
startServer, stopServer, refresh, setSelectedSlug, clearError,
|
||||
slug, cwdSlug, url, port, loading, error, stopped, isSuperAdmin, iframeKey, projects,
|
||||
startServer, stopServer, restartServer, refresh, setSelectedSlug, clearError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout, Loader2 } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
@@ -34,10 +34,6 @@ type LayoutTemplate = {
|
||||
};
|
||||
|
||||
const templates: LayoutTemplate[] = [
|
||||
{
|
||||
name: 'Single',
|
||||
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
|
||||
},
|
||||
{
|
||||
name: '2 Columns',
|
||||
layout: () => ({
|
||||
@@ -142,6 +138,40 @@ const templates: LayoutTemplate[] = [
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Cols + Split Bottom',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 30,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// --- Template Thumbnails ---
|
||||
@@ -233,8 +263,8 @@ const TemplatePanel = () => (
|
||||
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
||||
|
||||
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
||||
{ value: 'landing-page', label: 'Landing Page' },
|
||||
{ value: 'website', label: 'Website' },
|
||||
// { value: 'landing-page', label: 'Landing Page' },
|
||||
// { value: 'website', label: 'Website' },
|
||||
{ value: 'app', label: 'App' },
|
||||
];
|
||||
|
||||
@@ -294,7 +324,7 @@ const DetailsPanel = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{projectType === 'app' && (
|
||||
{/* {projectType === 'app' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -315,7 +345,7 @@ const DetailsPanel = () => {
|
||||
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -323,6 +353,21 @@ const DetailsPanel = () => {
|
||||
|
||||
// --- Panel: Create ---
|
||||
|
||||
const assignDefaultApps = (layout: LayoutNode, apps: string[]): LayoutNode => {
|
||||
const remaining = [...apps];
|
||||
const walk = (node: LayoutNode): LayoutNode => {
|
||||
if (remaining.length === 0) return node;
|
||||
if (node.type === 'panel' && node.appType === null) {
|
||||
return { ...node, appType: remaining.shift()! };
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
return { ...node, children: node.children.map((c) => ({ ...c, node: walk(c.node) })) };
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return walk(layout);
|
||||
};
|
||||
|
||||
const CreatePanel = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -339,13 +384,14 @@ const CreatePanel = () => {
|
||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isEditing = !!editingId;
|
||||
const slug = isEditing ? editingId : slugify(name.trim()) || generateSlug();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed || isSubmitting) return;
|
||||
const desc = description.trim();
|
||||
|
||||
const meta = {
|
||||
@@ -370,17 +416,23 @@ const CreatePanel = () => {
|
||||
let id = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setIsSubmitting(true);
|
||||
const finalLayout = projectType === 'app'
|
||||
? assignDefaultApps(previewLayout, ['officerdev/chat', 'officerdev/preview'])
|
||||
: previewLayout;
|
||||
|
||||
client
|
||||
.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: previewLayout })
|
||||
.then((res) => {
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||
navigate(`/projects/${id}`);
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
const res = await client.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout });
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
navigate(`/projects/${id}`);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -394,11 +446,11 @@ const CreatePanel = () => {
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim()}
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{isEditing ? 'Update Project' : 'Create Project'}
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||
{isSubmitting ? 'Creating...' : isEditing ? 'Update Project' : 'Create Project'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button
|
||||
|
||||
@@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
|
||||
onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
|
||||
});
|
||||
|
||||
@@ -73,6 +74,9 @@ export type UserSettings = {
|
||||
default: string;
|
||||
translateTo: string;
|
||||
};
|
||||
tts: {
|
||||
voice: string | null;
|
||||
};
|
||||
onboarding: {
|
||||
complete: boolean;
|
||||
};
|
||||
@@ -107,6 +111,9 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
default: 'en',
|
||||
translateTo: 'en',
|
||||
},
|
||||
tts: {
|
||||
voice: null,
|
||||
},
|
||||
onboarding: {
|
||||
complete: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user