This commit is contained in:
2026-02-25 06:59:29 +00:00
parent 88fff9efae
commit acd7713c86
37 changed files with 1581 additions and 69 deletions
@@ -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
+7
View File
@@ -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,
},