Skip to content

Commit c046dac

Browse files
necrodomeCharlieHelpsjchrisclaude
authored
feat: add app settings view (#199)
* WIP - feat: add app settings view * back to original useSession * change app name input style * fix(ResultPreviewHeaderContent): guard ShareButton on settings view behind publishedAppUrl; only show when preview ready or published URL exists * refactor(result-preview): extract exportHtml util and AppSettingsView; remove settings placeholder slot in IframeContent; stabilize useSession callbacks via mergeRef pattern - IframeContent: drop empty settings slot; add comment clarifying settings render outside iframe - utils/exportHtml: add generateStandaloneHtml() and downloadTextFile() - ResultPreview: use new util; factor settings JSX into AppSettingsView - useSession: use refs for mergeVibeDoc and vibeDoc; create updatedDoc objects and remove callback deps on vibeDoc/merge Verification: typecheck OK; vitest (scoped) OK for ResultPreviewHeaderContent, ViewControls, useViewState * test(exportHtml): add Vitest unit tests for generateStandaloneHtml and downloadTextFile * chore(format): prettier write for tests/exportHtml.test.ts * fix: eliminate Fireproof CRDT errors in ResultPreview tests - Add sessionId to mockResultPreviewProps to provide valid session ID - Mock useSession hook in ResultPreview tests to prevent Fireproof database initialization during testing - Resolves CRDT "Unknown type, must be binary type" errors introduced by app settings feature 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix: eliminate CRDT errors in IframeTemplate test - Add useSession mock to IframeTemplate.test.tsx to prevent Fireproof initialization - Resolves "Unknown type, must be binary type" CRDT errors from the iframe messaging test - Follows same pattern used in ResultPreview.test.tsx fix 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix: add missing CALLAI_ENDPOINT to test env mock - Add CALLAI_ENDPOINT to the env module mock in useSimpleChat setup - Resolves "Failed to generate title" warning in autoSelect test - Ensures titleGenerator has all required config values during testing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * feat: add manual title flag to prevent AI title generation overrides * test: add test suite for manual title preservation behavior --------- Co-authored-by: CharlieHelps <[email protected]> Co-authored-by: J Chris Anderson <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 5aae5bb commit c046dac

23 files changed

+697
-54
lines changed

app/components/HeaderContent/SvgIcons.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,32 @@ export const DataIcon: React.FC<SvgIconProps> = ({
143143
);
144144
};
145145

146+
export const SettingsIcon: React.FC<SvgIconProps> = ({ className = 'h-4 w-4', title }) => {
147+
return (
148+
<svg
149+
xmlns="http://www.w3.org/2000/svg"
150+
className={className}
151+
fill="none"
152+
viewBox="0 0 24 24"
153+
stroke="currentColor"
154+
>
155+
<title>{title || 'Settings icon'}</title>
156+
<path
157+
strokeLinecap="round"
158+
strokeLinejoin="round"
159+
strokeWidth={2}
160+
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
161+
/>
162+
<path
163+
strokeLinecap="round"
164+
strokeLinejoin="round"
165+
strokeWidth={2}
166+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
167+
/>
168+
</svg>
169+
);
170+
};
171+
146172
export const ShareIcon: React.FC<SvgIconProps> = ({ className = 'h-4 w-4', title }) => {
147173
return (
148174
<svg
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import React, { useCallback, useEffect, useRef, useState } from 'react';
2+
3+
type AppSettingsViewProps = {
4+
title: string;
5+
onUpdateTitle: (next: string, isManual?: boolean) => Promise<void> | void;
6+
onDownloadHtml: () => void;
7+
};
8+
9+
const AppSettingsView: React.FC<AppSettingsViewProps> = ({
10+
title,
11+
onUpdateTitle,
12+
onDownloadHtml,
13+
}) => {
14+
const [isEditingName, setIsEditingName] = useState(false);
15+
const [editedName, setEditedName] = useState(title);
16+
const nameInputRef = useRef<HTMLInputElement>(null);
17+
18+
useEffect(() => {
19+
setEditedName(title);
20+
}, [title]);
21+
22+
const handleEditNameStart = useCallback(() => {
23+
setIsEditingName(true);
24+
setTimeout(() => {
25+
nameInputRef.current?.focus();
26+
nameInputRef.current?.select();
27+
}, 0);
28+
}, []);
29+
30+
const handleNameSave = useCallback(async () => {
31+
const trimmedName = editedName.trim();
32+
if (trimmedName && trimmedName !== title) {
33+
await onUpdateTitle(trimmedName, true); // Mark as manually set
34+
}
35+
setIsEditingName(false);
36+
}, [editedName, title, onUpdateTitle]);
37+
38+
const handleNameCancel = useCallback(() => {
39+
setEditedName(title);
40+
setIsEditingName(false);
41+
}, [title]);
42+
43+
const handleNameKeyDown = useCallback(
44+
(e: React.KeyboardEvent<HTMLInputElement>) => {
45+
if (e.key === 'Enter') {
46+
handleNameSave();
47+
} else if (e.key === 'Escape') {
48+
handleNameCancel();
49+
}
50+
},
51+
[handleNameSave, handleNameCancel]
52+
);
53+
54+
return (
55+
<div className="flex h-full justify-center p-8 pt-16">
56+
<div className="w-full max-w-2xl">
57+
<h2 className="text-light-primary dark:text-dark-primary mb-6 text-center text-2xl font-semibold">
58+
App Settings
59+
</h2>
60+
61+
<div className="space-y-6">
62+
<div className="bg-light-background-01 dark:bg-dark-background-01 border-light-decorative-01 dark:border-dark-decorative-01 rounded-lg border p-6">
63+
<h3 className="text-light-primary dark:text-dark-primary mb-4 text-lg font-medium">
64+
General Settings
65+
</h3>
66+
<div className="space-y-4">
67+
<div className="space-y-1">
68+
<label className="text-light-primary dark:text-dark-primary block text-sm font-semibold">
69+
App Name
70+
</label>
71+
{isEditingName ? (
72+
<div className="flex items-center gap-2">
73+
<input
74+
ref={nameInputRef}
75+
type="text"
76+
value={editedName}
77+
onChange={(e) => setEditedName(e.target.value)}
78+
onKeyDown={handleNameKeyDown}
79+
onBlur={handleNameSave}
80+
className="dark:bg-dark-background-00 text-light-primary dark:text-dark-primary flex-1 rounded border-2 border-blue-500 bg-white px-3 py-2 text-sm shadow-sm transition-colors focus:border-blue-600 focus:ring-2 focus:ring-blue-500/30 focus:outline-none dark:border-blue-400 dark:focus:border-blue-300 dark:focus:ring-blue-400/30"
81+
placeholder="Enter app name"
82+
/>
83+
<button
84+
onClick={handleNameSave}
85+
className="hover:bg-light-decorative-01 dark:hover:bg-dark-decorative-01 rounded p-1 text-green-600 dark:text-green-400"
86+
title="Save"
87+
>
88+
<svg
89+
className="h-4 w-4"
90+
fill="none"
91+
stroke="currentColor"
92+
viewBox="0 0 24 24"
93+
>
94+
<path
95+
strokeLinecap="round"
96+
strokeLinejoin="round"
97+
strokeWidth={2}
98+
d="M5 13l4 4L19 7"
99+
/>
100+
</svg>
101+
</button>
102+
<button
103+
onClick={handleNameCancel}
104+
className="hover:bg-light-decorative-01 dark:hover:bg-dark-decorative-01 rounded p-1 text-red-600 dark:text-red-400"
105+
title="Cancel"
106+
>
107+
<svg
108+
className="h-4 w-4"
109+
fill="none"
110+
stroke="currentColor"
111+
viewBox="0 0 24 24"
112+
>
113+
<path
114+
strokeLinecap="round"
115+
strokeLinejoin="round"
116+
strokeWidth={2}
117+
d="M6 18L18 6M6 6l12 12"
118+
/>
119+
</svg>
120+
</button>
121+
</div>
122+
) : (
123+
<div className="flex items-center gap-2">
124+
<div className="dark:bg-dark-background-01 dark:border-dark-decorative-01 text-light-primary dark:text-dark-primary flex-1 cursor-default rounded border border-gray-200 bg-gray-50 px-3 py-2 font-medium">
125+
{title}
126+
</div>
127+
<button
128+
onClick={handleEditNameStart}
129+
className="bg-light-background-01 dark:bg-dark-decorative-01 text-light-secondary dark:text-dark-secondary hover:bg-light-background-02 dark:hover:bg-dark-decorative-00 focus:ring-light-border-01 dark:focus:ring-dark-border-01 rounded-md px-4 py-2 text-sm font-semibold shadow transition-colors focus:ring-1 focus:outline-none"
130+
>
131+
Edit
132+
</button>
133+
</div>
134+
)}
135+
</div>
136+
137+
<div className="opacity-60">
138+
<div className="text-light-primary dark:text-dark-primary mb-1 flex items-center gap-2 font-medium">
139+
Custom Domain
140+
<span className="inline-flex items-center rounded-full bg-gradient-to-r from-purple-500 to-pink-500 px-2 py-0.5 text-xs font-medium text-white">
141+
✨ Pro
142+
</span>
143+
<span className="inline-flex items-center rounded-full bg-orange-500 px-2 py-0.5 text-xs font-medium text-white">
144+
🚀 Soon
145+
</span>
146+
</div>
147+
<div className="text-light-primary/70 dark:text-dark-primary/70 text-sm">
148+
{title !== 'Untitled App'
149+
? `${title.toLowerCase().replace(/\s+/g, '-')}.vibesdiy.app`
150+
: 'app-name.vibesdiy.app'}
151+
</div>
152+
</div>
153+
</div>
154+
</div>
155+
156+
<div className="bg-light-background-01 dark:bg-dark-background-01 border-light-decorative-01 dark:border-dark-decorative-01 rounded-lg border p-6">
157+
<h3 className="text-light-primary dark:text-dark-primary mb-4 text-lg font-medium">
158+
Export Options
159+
</h3>
160+
<div className="space-y-3">
161+
<div
162+
className="bg-light-background-00 dark:bg-dark-background-00 border-light-decorative-01 dark:border-dark-decorative-01 hover:bg-light-decorative-01 dark:hover:bg-dark-decorative-01 flex cursor-pointer items-center rounded-lg border p-4 transition-colors"
163+
onClick={onDownloadHtml}
164+
>
165+
<div className="flex-1">
166+
<div className="text-light-primary dark:text-dark-primary font-medium">
167+
Download html
168+
</div>
169+
<div className="text-light-primary/70 dark:text-dark-primary/70 text-sm">
170+
Just open it in your browser.
171+
</div>
172+
</div>
173+
</div>
174+
</div>
175+
</div>
176+
</div>
177+
</div>
178+
</div>
179+
);
180+
};
181+
182+
export default AppSettingsView;

app/components/ResultPreview/IframeContent.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { transformImports } from './transformImports';
1212
import iframeTemplateRaw from './templates/iframe-template.html?raw';
1313

1414
interface IframeContentProps {
15-
activeView: 'preview' | 'code' | 'data' | 'chat';
15+
activeView: 'preview' | 'code' | 'data' | 'chat' | 'settings';
1616
filesContent: IframeFiles;
1717
isStreaming: boolean;
1818
codeReady: boolean;
@@ -198,6 +198,7 @@ const IframeContent: React.FC<IframeContentProps> = ({
198198
if (path.endsWith('/data')) return 'data';
199199
if (path.endsWith('/app')) return 'preview';
200200
if (path.endsWith('/chat')) return 'preview'; // Show preview for chat view
201+
if (path.endsWith('/settings')) return 'settings';
201202
return activeView; // Fall back to state if path doesn't have a suffix
202203
};
203204

@@ -353,6 +354,11 @@ const IframeContent: React.FC<IframeContentProps> = ({
353354
/>
354355
</div>
355356
</div>
357+
{/**
358+
* Settings view is rendered by the parent ResultPreview component, not inside
359+
* the iframe. We intentionally do not render a placeholder slot here to avoid
360+
* any chance of intercepting pointer events or impacting layout.
361+
*/}
356362
</div>
357363
);
358364
};

app/components/ResultPreview/ResultPreview.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import React, { useEffect, useMemo } from 'react';
2-
import { animationStyles } from './ResultPreviewTemplates';
3-
import type { ResultPreviewProps, IframeFiles } from './ResultPreviewTypes';
1+
import React, { useCallback, useEffect, useMemo } from 'react';
42
import type { RuntimeError } from '../../hooks/useRuntimeErrors';
3+
import { useSession } from '../../hooks/useSession';
4+
import { animationStyles } from './ResultPreviewTemplates';
5+
import type { IframeFiles, ResultPreviewProps } from './ResultPreviewTypes';
56
// import { encodeTitle } from '../SessionSidebar/utils';
67
// ResultPreview component
78
import IframeContent from './IframeContent';
9+
import AppSettingsView from './AppSettingsView';
10+
import { downloadTextFile, generateStandaloneHtml } from '~/utils/exportHtml';
811

912
function ResultPreview({
1013
code,
14+
dependencies,
1115
onScreenshotCaptured,
1216
sessionId,
17+
title,
1318
isStreaming = false,
1419
codeReady = false,
1520
displayView,
@@ -18,16 +23,21 @@ function ResultPreview({
1823
setIsIframeFetching,
1924
addError,
2025
children,
21-
title,
2226
onCodeSave,
2327
onCodeChange,
2428
onSyntaxErrorChange,
2529
}: ResultPreviewProps & { children?: React.ReactNode }) {
2630
// Use CSS-based dark mode detection like the rest of the UI
2731
const isDarkMode =
2832
typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : true; // Default to dark mode for SSR
33+
const { updateTitle, session } = useSession(sessionId);
2934
const showWelcome = !isStreaming && (!code || code.length === 0);
3035

36+
// Use session title if available, otherwise fall back to prop
37+
const currentTitle = session?.title || title || 'Untitled App';
38+
39+
// Settings view callbacks handled in AppSettingsView
40+
3141
// Calculate filesContent directly based on code prop
3242
const filesContent = useMemo<IframeFiles>(() => {
3343
// Always return the expected structure, defaulting code to empty string
@@ -41,6 +51,25 @@ function ResultPreview({
4151

4252
// Theme is now provided by ThemeContext
4353

54+
// Function to download HTML file
55+
const handleDownloadHtml = useCallback(async () => {
56+
try {
57+
const html = generateStandaloneHtml({ code, sessionId });
58+
const name = currentTitle !== 'Untitled App' ? currentTitle : 'app';
59+
downloadTextFile(`${name}.html`, html);
60+
} catch (error) {
61+
console.error('Failed to download HTML:', error);
62+
if (addError) {
63+
addError({
64+
type: 'error',
65+
message: 'Failed to download HTML file',
66+
source: 'download-html',
67+
timestamp: new Date().toISOString(),
68+
});
69+
}
70+
}
71+
}, [code, sessionId, currentTitle, addError]);
72+
4473
useEffect(() => {
4574
const handleMessage = ({ data }: MessageEvent) => {
4675
if (data) {
@@ -80,11 +109,17 @@ function ResultPreview({
80109
setMobilePreviewShown,
81110
addError,
82111
sessionId,
83-
title,
112+
currentTitle,
84113
]);
85114

86115
const previewArea = showWelcome ? (
87116
<div className="h-full">{/* empty div to prevent layout shift */}</div>
117+
) : displayView === 'settings' ? (
118+
<AppSettingsView
119+
title={currentTitle}
120+
onUpdateTitle={updateTitle}
121+
onDownloadHtml={handleDownloadHtml}
122+
/>
88123
) : (
89124
<IframeContent
90125
activeView={displayView}

app/components/ResultPreview/ResultPreviewHeaderContent.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { /*useEffect,*/ useRef } from 'react'; // useEffect no longer needed here
22
import { useParams } from 'react-router';
33
import { useSession } from '../../hooks/useSession';
4-
import type { ViewType, ViewControlsType } from '../../utils/ViewState';
4+
import type { ViewControlsType, ViewType } from '../../utils/ViewState';
55
// import { useViewState } from '../../utils/ViewState'; // useViewState is now lifted to home.tsx
66
import { BackButton } from './BackButton';
7-
import { ViewControls } from './ViewControls';
8-
import { ShareButton } from './ShareButton';
97
import { SaveButton } from './SaveButton';
10-
import { usePublish } from './usePublish';
8+
import { ShareButton } from './ShareButton';
119
import { ShareModal } from './ShareModal';
10+
import { usePublish } from './usePublish';
11+
import { ViewControls } from './ViewControls';
1212

1313
interface ResultPreviewHeaderContentProps {
1414
// Props from useViewState (lifted to home.tsx)
@@ -124,15 +124,16 @@ const ResultPreviewHeaderContent: React.FC<ResultPreviewHeaderContentProps> = ({
124124
/>
125125
)}
126126

127-
{showViewControls && previewReady && (
128-
<ShareButton
129-
ref={publishButtonRef}
130-
onClick={toggleShareModal}
131-
isPublishing={isPublishing}
132-
urlCopied={urlCopied}
133-
hasPublishedUrl={!!publishedAppUrl}
134-
/>
135-
)}
127+
{showViewControls &&
128+
(previewReady || (displayView === 'settings' && publishedAppUrl)) && (
129+
<ShareButton
130+
ref={publishButtonRef}
131+
onClick={toggleShareModal}
132+
isPublishing={isPublishing}
133+
urlCopied={urlCopied}
134+
hasPublishedUrl={!!publishedAppUrl}
135+
/>
136+
)}
136137
</div>
137138
</div>
138139
{/* Share Modal */}

app/components/ResultPreview/ViewControls.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import type { ViewType } from '../../utils/ViewState';
3-
import { CodeIcon, DataIcon, PreviewIcon } from '../HeaderContent/SvgIcons';
3+
import { CodeIcon, DataIcon, PreviewIcon, SettingsIcon } from '../HeaderContent/SvgIcons';
44

55
interface ViewControlsProps {
66
viewControls: Record<
@@ -56,6 +56,7 @@ export const ViewControls: React.FC<ViewControlsProps> = ({
5656
/>
5757
)}
5858
{viewTypeKey === 'data' && <DataIcon className="h-3.5 w-3.5 sm:h-4 sm:w-4" />}
59+
{viewTypeKey === 'settings' && <SettingsIcon className="h-4 w-4" />}
5960
<span className="hidden min-[480px]:inline">{control.label}</span>
6061
</button>
6162
);

0 commit comments

Comments
 (0)