diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx index 41409d9f4c..edafe53ae6 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx @@ -40,6 +40,31 @@ export const Border = observer(() => { const [activeTab, setActiveTab] = useState(areAllBordersEqual ? BorderTab.ALL : BorderTab.INDIVIDUAL); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === BorderTab.ALL && !areAllBordersEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handleBorderChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('borderWidth', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: BorderTab) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getBorderDisplay = () => { const top = boxState.borderTopWidth.num ?? 0; const right = boxState.borderRightWidth.num ?? 0; @@ -64,7 +89,7 @@ export const Border = observer(() => { const borderValue = getBorderDisplay() return ( - + { >
{activeTab === BorderTab.ALL ? ( handleBoxChange('borderWidth', value.toString())} + value={shouldShowMixed ? 0 : (boxState.borderWidth.num ?? 0)} + onChange={handleBorderChange} unit={boxState.borderWidth.unit} onUnitChange={(unit) => handleUnitChange('borderWidth', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : ( { const [activeTab, setActiveTab] = useState(areAllMarginsEqual ? MarginTab.ALL : MarginTab.INDIVIDUAL); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === MarginTab.ALL && !areAllMarginsEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handleMarginChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('margin', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: MarginTab) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getMarginIcon = () => { const margins = { top: boxState.marginTop.num ?? 0, @@ -148,7 +173,7 @@ export const Margin = observer(() => { const marginValue = getMarginDisplay(); return ( - + { >
{activeTab === MarginTab.ALL ? ( handleBoxChange('margin', value.toString())} + value={shouldShowMixed ? 0 : (boxState.margin.num ?? 0)} + onChange={handleMarginChange} unit={boxState.margin.unit} onUnitChange={(unit) => handleUnitChange('margin', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : ( { const [activeTab, setActiveTab] = useState(areAllPaddingsEqual ? PaddingTab.ALL : PaddingTab.INDIVIDUAL); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === PaddingTab.ALL && !areAllPaddingsEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handlePaddingChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('padding', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: PaddingTab) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getPaddingIcon = () => { const paddings = { top: boxState.paddingTop.num ?? 0, @@ -123,7 +148,7 @@ export const Padding = observer(() => { const paddingValue = getPaddingDisplay(); return ( - + {
{activeTab === PaddingTab.ALL ? ( handleBoxChange('padding', value.toString())} + value={shouldShowMixed ? 0 : (boxState.padding.num ?? 0)} + onChange={handlePaddingChange} unit={boxState.padding.unit} onUnitChange={(unit) => handleUnitChange('padding', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : ( { - const [activeTab, setActiveTab] = useState('all'); const { boxState, handleBoxChange, handleUnitChange, handleIndividualChange } = useBoxControl('radius'); + const areAllCornersEqual = useMemo((): boolean => { + const corners = { + topLeft: boxState.borderTopLeftRadius.num ?? 0, + topRight: boxState.borderTopRightRadius.num ?? 0, + bottomRight: boxState.borderBottomRightRadius.num ?? 0, + bottomLeft: boxState.borderBottomLeftRadius.num ?? 0, + }; + + const values = Object.values(corners); + + return values.every(val => val === values[0]); + }, [boxState.borderTopLeftRadius.num, boxState.borderTopRightRadius.num, boxState.borderBottomRightRadius.num, boxState.borderBottomLeftRadius.num]); + + const [activeTab, setActiveTab] = useState(areAllCornersEqual ? 'all' : 'individual'); + const { isOpen, onOpenChange } = useDropdownControl({ id: 'radius-dropdown' }); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === 'all' && !areAllCornersEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handleRadiusChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('borderRadius', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: string) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getRadiusIcon = () => { const topLeft = boxState.borderTopLeftRadius.num ?? 0; const topRight = boxState.borderTopRightRadius.num ?? 0; @@ -101,7 +140,7 @@ export const Radius = observer(() => { const radiusValue = getRadiusDisplay(); return ( - + {
{activeTab === 'all' ? ( handleBoxChange('borderRadius', value.toString())} + value={shouldShowMixed ? 0 : (boxState.borderRadius.num ?? 0)} + onChange={handleRadiusChange} unit={boxState.borderRadius.unit} onUnitChange={(unit) => handleUnitChange('borderRadius', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : ( void) => { +export const useInputControl = (value: number, onChange?: (value: number) => void, allowNegative: boolean = false, minValue?: number, maxValue?: number, stepSize: number = 0.1) => { const [localValue, setLocalValue] = useState(String(value)); + const isEditingRef = useRef(false); useEffect(() => { - setLocalValue(String(value)); + // Only update local value from prop if not actively editing + if (!isEditingRef.current) { + setLocalValue(String(value)); + } }, [value]); const handleIncrement = (step: number) => { - const currentValue = Number(localValue); - if (!isNaN(currentValue)) { - const newValue = currentValue + step; - setLocalValue(String(newValue)); - debouncedOnChange(newValue); - } + isEditingRef.current = true; + const currentValue = parseFloat(localValue) || 0; + let newValue = allowNegative ? currentValue + step : Math.max(0, currentValue + step); + + // Apply bounds if specified + if (minValue !== undefined) newValue = Math.max(minValue, newValue); + if (maxValue !== undefined) newValue = Math.min(maxValue, newValue); + + // Round to 2 decimal places for display + const roundedValue = Math.round(newValue * 100) / 100; + setLocalValue(String(roundedValue)); + debouncedOnChange(roundedValue); }; const handleChange = (inputValue: string) => { + isEditingRef.current = true; setLocalValue(inputValue); - const numValue = Number(inputValue); + const numValue = parseFloat(inputValue); if (!isNaN(numValue)) { - debouncedOnChange(numValue); + let roundedValue = Math.round(numValue * 100) / 100; + + // Apply bounds if specified + if (minValue !== undefined) roundedValue = Math.max(minValue, roundedValue); + if (maxValue !== undefined) roundedValue = Math.min(maxValue, roundedValue); + + debouncedOnChange(roundedValue); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); - const step = e.shiftKey ? 10 : 1; + const step = e.shiftKey ? stepSize * 10 : stepSize; const direction = e.key === 'ArrowUp' ? 1 : -1; handleIncrement(step * direction); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.currentTarget.blur(); } }; const debouncedOnChange = useMemo( () => debounce((newValue: number) => { onChange?.(newValue); - }, 500), + // Reset editing flag after a delay to allow external updates again + setTimeout(() => { + isEditingRef.current = false; + }, 100); + }, 300), [onChange] ); diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts b/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts index 53c1d049c0..5b438ab3cf 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts @@ -50,17 +50,26 @@ export const useTextControl = () => { textColor: editorEngine.style.selectedStyle?.styles.computed.color ?? DefaultState.textColor, letterSpacing: - editorEngine.style.selectedStyle?.styles.computed.letterSpacing?.toString() ?? - DefaultState.letterSpacing, + parseFloat(editorEngine.style.selectedStyle?.styles.computed.letterSpacing?.toString() ?? DefaultState.letterSpacing).toString() || DefaultState.letterSpacing, capitalization: editorEngine.style.selectedStyle?.styles.computed.textTransform?.toString() ?? DefaultState.capitalization, textDecorationLine: editorEngine.style.selectedStyle?.styles.computed.textDecorationLine?.toString() ?? DefaultState.textDecorationLine, - lineHeight: - editorEngine.style.selectedStyle?.styles.computed.lineHeight?.toString() ?? - DefaultState.lineHeight, + lineHeight: (() => { + const rawValue = editorEngine.style.selectedStyle?.styles.computed.lineHeight?.toString() ?? DefaultState.lineHeight; + // Handle both unitless values (1.5) and pixel values (24px) + let parsed = parseFloat(rawValue); + // If it's a pixel value (like 24px), convert to unitless by dividing by font size + if (rawValue.includes('px')) { + const fontSize = parseInt(editorEngine.style.selectedStyle?.styles.computed.fontSize?.toString() ?? '16'); + parsed = parsed / fontSize; + } + // Clamp to reasonable line height range (0.5 to 5.0) + const clamped = Math.max(0.5, Math.min(5.0, parsed)); + return isNaN(parsed) ? DefaultState.lineHeight : clamped.toString(); + })(), }; }; @@ -71,6 +80,10 @@ export const useTextControl = () => { }, [editorEngine.style.selectedStyle]); const handleFontFamilyChange = (fontFamily: Font) => { + setTextState((prev) => ({ + ...prev, + fontFamily: fontFamily.id, + })); editorEngine.style.updateFontFamily('fontFamily', fontFamily); // Reload all views after a delay to ensure the font is applied setTimeout(async () => { diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-icon.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-icon.tsx index 2a6b360630..da459cee8a 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-icon.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-icon.tsx @@ -26,11 +26,15 @@ interface InputIconProps { icon?: IconType; onChange?: (value: number) => void; onUnitChange?: (unit: string) => void; + allowNegative?: boolean; + minValue?: number; + maxValue?: number; + stepSize?: number; } -export const InputIcon = ({ value, unit = 'px', icon, onChange, onUnitChange }: InputIconProps) => { +export const InputIcon = ({ value, unit = 'px', icon, onChange, onUnitChange, allowNegative = false, minValue, maxValue, stepSize = 0.1 }: InputIconProps) => { const [unitValue, setUnitValue] = useState(unit); - const { localValue, handleKeyDown, handleChange } = useInputControl(value, onChange); + const { localValue, handleKeyDown, handleChange } = useInputControl(value, onChange, allowNegative, minValue, maxValue, stepSize); const IconComponent = icon ? Icons[icon] : null; diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx index 670fefa872..a7cf46f2ce 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx @@ -15,6 +15,7 @@ interface InputRangeProps { unit?: string; onChange?: (value: number) => void; onUnitChange?: (unit: string) => void; + displayValue?: string; } export const InputRange = ({ @@ -23,8 +24,10 @@ export const InputRange = ({ unit = 'px', onChange, onUnitChange, + displayValue, }: InputRangeProps) => { const [localValue, setLocalValue] = useState(String(value)); + const [isTyping, setIsTyping] = useState(false); const rangeRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -32,6 +35,7 @@ export const InputRange = ({ const debouncedOnChange = useMemo( () => debounce((newValue: number) => { onChange?.(newValue); + setIsTyping(false); // Reset typing state after onChange fires }, 500), [onChange] ); @@ -53,6 +57,7 @@ export const InputRange = ({ const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setLocalValue(newValue); + setIsTyping(true); }; const handleBlur = () => { @@ -61,6 +66,7 @@ export const InputRange = ({ debouncedOnChange(numValue); } else { setLocalValue(String(value)); + setIsTyping(false); } }; @@ -77,6 +83,7 @@ export const InputRange = ({ } } else if (e.key === 'Enter') { handleBlur(); + e.currentTarget.blur(); // Unfocus the input } }; @@ -119,23 +126,35 @@ export const InputRange = ({ debouncedOnChange(newValue); }} onMouseDown={handleMouseDown} - className="flex-1 h-3 bg-background-tertiary/50 rounded-full appearance-none cursor-pointer relative + className={`flex-1 h-3 bg-background-tertiary/50 rounded-full appearance-none relative [&::-webkit-slider-runnable-track]:bg-background-tertiary/50 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-3 [&::-moz-range-track]:bg-background-tertiary/50 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-3 - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:mt-[-2px] [&::-webkit-slider-thumb]:cursor-grab hover:[&::-webkit-slider-thumb]:bg-white/90 active:[&::-webkit-slider-thumb]:cursor-grabbing - [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-grab hover:[&::-moz-range-thumb]:bg-white/90 active:[&::-moz-range-thumb]:cursor-grabbing - [&::-ms-thumb]:appearance-none [&::-ms-thumb]:w-4 [&::-ms-thumb]:h-4 [&::-ms-thumb]:rounded-full [&::-ms-thumb]:bg-white [&::-ms-thumb]:cursor-grab hover:[&::-ms-thumb]:bg-white/90 active:[&::-ms-thumb]:cursor-grabbing" + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:mt-[-2px] [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:cursor-grab hover:[&::-webkit-slider-thumb]:bg-white/90 active:[&::-webkit-slider-thumb]:cursor-grabbing + [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:cursor-grab hover:[&::-moz-range-thumb]:bg-white/90 active:[&::-moz-range-thumb]:cursor-grabbing + [&::-ms-thumb]:appearance-none [&::-ms-thumb]:w-4 [&::-ms-thumb]:h-4 [&::-ms-thumb]:rounded-full [&::-ms-thumb]:bg-white [&::-ms-thumb]:cursor-grab hover:[&::-ms-thumb]:bg-white/90 active:[&::-ms-thumb]:cursor-grabbing + ${displayValue === 'Mixed' ? 'opacity-50 hover:opacity-100 [&::-webkit-slider-thumb]:opacity-0 hover:[&::-webkit-slider-thumb]:opacity-100 [&::-webkit-slider-thumb]:bg-muted-foreground hover:[&::-webkit-slider-thumb]:bg-foreground-primary [&::-moz-range-thumb]:opacity-0 hover:[&::-moz-range-thumb]:opacity-100 [&::-moz-range-thumb]:bg-muted-foreground hover:[&::-moz-range-thumb]:bg-foreground-primary [&::-ms-thumb]:opacity-0 hover:[&::-ms-thumb]:opacity-100 [&::-ms-thumb]:bg-muted-foreground hover:[&::-ms-thumb]:bg-foreground-primary' : 'cursor-pointer'}`} />
{ + if (displayValue) { + e.target.select(); + setLocalValue(''); + setIsTyping(true); + } + }} + className={`min-w-[40px] max-w-[40px] bg-transparent text-sm focus:outline-none input-range-text ${ + displayValue === 'Mixed' + ? 'text-muted-foreground hover:text-white' + : 'text-white' + }`} /> diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx index 1c3c418fb2..a7c438f091 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx @@ -61,6 +61,7 @@ export const SpacingInputs = ({ type, values, onChange }: SpacingInputsProps) => icon={icons[pos]} value={values[pos as keyof typeof values] ?? 0} onChange={(value) => onChange(value, pos)} + stepSize={1} /> ))}
diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx index c770ce7bab..2c5af63544 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx @@ -77,7 +77,7 @@ export const AdvancedTypography = () => { -
+
Color
@@ -88,8 +88,11 @@ export const AdvancedTypography = () => { Line
handleLineHeightChange(value.toString())} + minValue={0.2} + maxValue={15.0} + stepSize={0.1} />
@@ -99,6 +102,8 @@ export const AdvancedTypography = () => { handleLetterSpacingChange(value.toString())} + allowNegative={true} + stepSize={0.1} />
diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx index 6508c00c03..6b3ba378dd 100644 --- a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx @@ -3,21 +3,17 @@ import { MODEL_MAX_TOKENS, OPENROUTER_MODELS } from '@onlook/models'; import { Context, - ContextCacheUsage, ContextContent, - ContextContentBody, - ContextContentFooter, - ContextContentHeader, - ContextInputUsage, - ContextOutputUsage, - ContextReasoningUsage, ContextTrigger } from '@onlook/ui/ai-elements/context'; +import { Button } from '@onlook/ui/button'; import type { LanguageModelUsage } from 'ai'; import { useMemo } from 'react'; +import { useEditorEngine } from '@/components/store/editor'; export const ChatContextWindow = ({ usage }: { usage: LanguageModelUsage }) => { - const showCost = false; + const editorEngine = useEditorEngine(); + // Hardcoded for now, but should be dynamic based on the model used const maxTokens = MODEL_MAX_TOKENS[OPENROUTER_MODELS.CLAUDE_4_5_SONNET]; const usedTokens = useMemo(() => { @@ -27,6 +23,11 @@ export const ChatContextWindow = ({ usage }: { usage: LanguageModelUsage }) => { return input + cached; }, [usage]); + const handleNewChat = () => { + editorEngine.chat.conversation.startNewConversation(); + editorEngine.chat.focusChatInput(); + }; + return ( { > - - - - - - - - {showCost && } +
+

Context available

+ +
); diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx index 9e5f292110..9d1778473e 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx @@ -19,12 +19,16 @@ import { observer } from 'mobx-react-lite'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { ProjectLoadingType } from '../../_hooks/use-project-loading'; interface NewProjectMenuProps { onShowCloneDialog: (open: boolean) => void; + setLoading: (type: ProjectLoadingType) => void; + clearLoading: () => void; + loadingType: ProjectLoadingType; } -export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuProps) => { +export const NewProjectMenu = observer(({ onShowCloneDialog, setLoading, clearLoading, loadingType }: NewProjectMenuProps) => { const editorEngine = useEditorEngine(); const { data: user } = api.user.get.useQuery(); const { mutateAsync: forkSandbox } = api.sandbox.fork.useMutation(); @@ -32,7 +36,6 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro const { setIsAuthModalOpen } = useAuthContext(); const t = useTranslations(); const router = useRouter(); - const [isCreatingProject, setIsCreatingProject] = useState(false); const handleStartBlankProject = async () => { if (!user?.id) { @@ -42,7 +45,7 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro return; } - setIsCreatingProject(true); + setLoading('creating-blank-project'); try { // Capture screenshot of current project before cleanup try { @@ -88,7 +91,7 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro }); } } finally { - setIsCreatingProject(false); + clearLoading(); } }; @@ -112,11 +115,11 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro
- {isCreatingProject ? ( + {loadingType === 'creating-blank-project' ? ( ) : ( diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx index 591577d25f..a98669b513 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx @@ -21,6 +21,7 @@ import { redirect } from 'next/navigation'; import { usePostHog } from 'posthog-js/react'; import { useRef, useState } from 'react'; import { CloneProjectDialog } from '../clone-project-dialog'; +import { useProjectLoading } from '../../_hooks/use-project-loading'; import { NewProjectMenu } from './new-project-menu'; import { RecentProjectsMenu } from './recent-projects'; @@ -34,20 +35,19 @@ export const ProjectBreadcrumb = observer(() => { const t = useTranslations(); const closeTimeoutRef = useRef(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isClosingProject, setIsClosingProject] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); const [showCloneDialog, setShowCloneDialog] = useState(false); + const { loadingType, isLoading, setLoading, clearLoading } = useProjectLoading(); async function handleNavigateToProjects(_route?: 'create' | 'import') { try { - setIsClosingProject(true); + setLoading('closing-project'); editorEngine.screenshot.captureScreenshot(); } catch (error) { console.error('Failed to take screenshots:', error); } finally { setTimeout(() => { - setIsClosingProject(false); + clearLoading(); redirect('/projects'); }, 100); } @@ -66,7 +66,7 @@ export const ProjectBreadcrumb = observer(() => { } try { - setIsDownloading(true); + setLoading('downloading-code'); const result = await editorEngine.activeSandbox.downloadFiles(project.name); @@ -93,7 +93,7 @@ export const ProjectBreadcrumb = observer(() => { error: error instanceof Error ? error.message : 'Unknown error', }); } finally { - setIsDownloading(false); + clearLoading(); } } @@ -105,14 +105,19 @@ export const ProjectBreadcrumb = observer(() => { variant='ghost' className="ml-1 px-0 gap-2 text-foreground-onlook text-small hover:text-foreground-active hover:!bg-transparent cursor-pointer group" > -
- + - +
- - {isDownloading + {loadingType === 'downloading-code' ? ( + + ) : ( + + )} + {loadingType === 'downloading-code' ? t(transKeys.projects.actions.downloadingCode) : t(transKeys.projects.actions.downloadCode)}
diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx index 1b82f0d6b2..53e3d194cc 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx @@ -16,13 +16,18 @@ import { observer } from 'mobx-react-lite'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { ProjectLoadingType } from '../../_hooks/use-project-loading'; -export const RecentProjectsMenu = observer(() => { +interface RecentProjectsMenuProps { + setLoading: (type: ProjectLoadingType) => void; + clearLoading: () => void; +} + +export const RecentProjectsMenu = observer(({ setLoading, clearLoading }: RecentProjectsMenuProps) => { const editorEngine = useEditorEngine(); const currentProjectId = editorEngine.projectId; const router = useRouter(); const t = useTranslations(); - const [loadingProjectId, setLoadingProjectId] = useState(null); const { data: projects, isLoading: isLoadingProjects } = api.project.list.useQuery({ limit: 5, @@ -34,7 +39,7 @@ export const RecentProjectsMenu = observer(() => { || []; const handleProjectClick = async (projectId: string) => { - setLoadingProjectId(projectId); + setLoading('switching-project'); router.push(`${Routes.PROJECT}/${projectId}`); }; @@ -100,15 +105,10 @@ export const RecentProjectsMenu = observer(() => { handleProjectClick(project.id)} - disabled={loadingProjectId === project.id} className="cursor-pointer" >
- {loadingProjectId === project.id ? ( - - ) : ( - - )} + {project.name} diff --git a/apps/web/client/src/app/project/[id]/_hooks/use-project-loading.tsx b/apps/web/client/src/app/project/[id]/_hooks/use-project-loading.tsx new file mode 100644 index 0000000000..4aa9228d04 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_hooks/use-project-loading.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +export type ProjectLoadingType = + | 'creating-blank-project' + | 'switching-project' + | 'closing-project' + | 'downloading-code' + | null; + +export const useProjectLoading = () => { + const [loadingType, setLoadingType] = useState(null); + + const setLoading = useCallback((type: ProjectLoadingType) => { + setLoadingType(type); + }, []); + + const clearLoading = useCallback(() => { + setLoadingType(null); + }, []); + + const isLoading = loadingType !== null; + + return { + loadingType, + isLoading, + setLoading, + clearLoading, + }; +}; diff --git a/apps/web/client/src/server/api/routers/project/fork.ts b/apps/web/client/src/server/api/routers/project/fork.ts index 7d276da033..b4336d0e59 100644 --- a/apps/web/client/src/server/api/routers/project/fork.ts +++ b/apps/web/client/src/server/api/routers/project/fork.ts @@ -154,14 +154,7 @@ function createDefaultFramesForDefaultBranch( type: DefaultFrameType.DESKTOP, }); - const mobileFrame = createDefaultFrame({ - canvasId, - branchId: defaultBranchMap.newBranch.id, - url: defaultBranchMap.newSandboxUrl, - type: DefaultFrameType.MOBILE, - }); - - return [desktopFrame, mobileFrame]; + return [desktopFrame]; } export const fork = protectedProcedure diff --git a/apps/web/client/src/server/api/routers/project/project.ts b/apps/web/client/src/server/api/routers/project/project.ts index 72955b4b41..a110db5bbf 100644 --- a/apps/web/client/src/server/api/routers/project/project.ts +++ b/apps/web/client/src/server/api/routers/project/project.ts @@ -284,13 +284,6 @@ export const projectRouter = createTRPCRouter({ type: DefaultFrameType.DESKTOP, }); await tx.insert(frames).values(desktopFrame); - const mobileFrame = createDefaultFrame({ - canvasId: newCanvas.id, - branchId: newBranch.id, - url: input.sandboxUrl, - type: DefaultFrameType.MOBILE, - }); - await tx.insert(frames).values(mobileFrame); // 6. Create the default chat conversation await tx.insert(conversations).values(createDefaultConversation(newProject.id)); diff --git a/packages/db/src/defaults/frame.ts b/packages/db/src/defaults/frame.ts index 13258702be..4f3051630a 100644 --- a/packages/db/src/defaults/frame.ts +++ b/packages/db/src/defaults/frame.ts @@ -7,8 +7,8 @@ export enum DefaultFrameType { } export const DefaultDesktopFrame = { - x: '5', - y: '0', + x: '150', + y: '40', width: '1536', height: '960', } as const;