Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 18 additions & 80 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,17 @@
import { useState } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { ArrowLeft, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
import { Button } from "@/components/ui/button";
import { ProjectListPage } from "@/routes/ProjectListPage";
import { ProjectEditorPage } from "@/routes/ProjectEditorPage";
import { LanguageSelect } from "@/components/LanguageSelect";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Header } from "@/components/Header";
import { UnsavedChangesDialog } from "@/components/UnsavedChangesDialog";

import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";

type View = { mode: "list" } | { mode: "edit"; projectId: string | null };

function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const { t } = useTranslation();
return (
<Button
variant="ghost"
size="icon"
className="relative size-8"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
aria-label={t("common.toggleTheme")}
>
<Sun className="size-4 scale-100 transition-transform dark:scale-0" />
<Moon className="absolute size-4 scale-0 transition-transform dark:scale-100" />
</Button>
);
}
export type View =
| { mode: "list" }
| { mode: "edit"; projectId: string | null };

function AppShell() {
const { t } = useTranslation();
const [view, setView] = useState<View>({ mode: "list" });
const [isDirty, setIsDirty] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
Expand All @@ -52,32 +25,15 @@ function AppShell() {
}
}

function handleConfirmDiscard() {
setShowUnsavedDialog(false);
setIsDirty(false);
setView({ mode: "list" });
}

return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex h-12 max-w-3xl items-center justify-between px-6">
{isEditing ? (
<Button
variant="ghost"
size="sm"
className="-ml-2"
onClick={handleBackClick}
>
<ArrowLeft className="size-3.5" />
{t("common.back")}
</Button>
) : (
<div className="flex items-center gap-2">
<img src="/icon.svg" alt="CodeLaunch" className="size-5" />
<span className="text-sm font-semibold">CodeLaunch</span>
</div>
)}
<div className="flex items-center gap-2">
<LanguageSelect />
<ThemeToggle />
</div>
</div>
</header>
<Header isEditing={isEditing} onBack={handleBackClick} />

<ErrorBoundary onReset={() => setView({ mode: "list" })}>
{isEditing ? (
Expand All @@ -95,29 +51,11 @@ function AppShell() {
)}
</ErrorBoundary>

<Dialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("editor.unsavedChangesDialog.title")}</DialogTitle>
<DialogDescription>{t("editor.unsavedChangesDialog.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUnsavedDialog(false)}>
{t("editor.unsavedChangesDialog.keepEditing")}
</Button>
<Button
variant="destructive"
onClick={() => {
setShowUnsavedDialog(false);
setIsDirty(false);
setView({ mode: "list" });
}}
>
{t("editor.unsavedChangesDialog.discard")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UnsavedChangesDialog
open={showUnsavedDialog}
onClose={() => setShowUnsavedDialog(false)}
onConfirm={handleConfirmDiscard}
/>

<Toaster />
</div>
Expand Down
41 changes: 41 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ArrowLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSelect } from "./LanguageSelect";
import { ThemeToggle } from "./ThemeToggle";
import { Button } from "./ui/button";

interface HeaderProps {
isEditing: boolean;
onBack?: () => void;
}

export function Header({ isEditing, onBack }: HeaderProps) {
const { t } = useTranslation();

return (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex h-12 max-w-3xl items-center justify-between px-6">
{isEditing ? (
<Button
variant="ghost"
size="sm"
className="-ml-2"
onClick={onBack}
>
<ArrowLeft className="size-3.5" />
{t("common.back")}
</Button>
) : (
<div className="flex items-center gap-2">
<img src="/icon.svg" alt="CodeLaunch" className="size-5" />
<span className="text-sm font-semibold">CodeLaunch</span>
</div>
)}
<div className="flex items-center gap-2">
<LanguageSelect />
<ThemeToggle />
</div>
</div>
</header>
);
}
21 changes: 21 additions & 0 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Sun, Moon } from "lucide-react";
import { useTheme } from "next-themes";
import { useTranslation } from "react-i18next";
import { Button } from "./ui/button";

export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const { t } = useTranslation();
return (
<Button
variant="ghost"
size="icon"
className="relative size-8"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
aria-label={t("common.toggleTheme")}
>
<Sun className="size-4 scale-100 transition-transform dark:scale-0" />
<Moon className="absolute size-4 scale-0 transition-transform dark:scale-100" />
</Button>
);
}
45 changes: 45 additions & 0 deletions src/components/UnsavedChangesDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";

interface UnsavedChangesDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
}

export function UnsavedChangesDialog({
open,
onClose,
onConfirm,
}: UnsavedChangesDialogProps) {
const { t } = useTranslation();

return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("editor.unsavedChangesDialog.title")}</DialogTitle>
<DialogDescription>
{t("editor.unsavedChangesDialog.description")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("editor.unsavedChangesDialog.keepEditing")}
</Button>
<Button variant="destructive" onClick={onConfirm}>
{t("editor.unsavedChangesDialog.discard")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
24 changes: 24 additions & 0 deletions src/components/editor/EditorBottomBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";

interface EditorBottomBarProps {
onSave: (andOpen: boolean) => void;
}

export function EditorBottomBar({ onSave }: EditorBottomBarProps) {
const { t } = useTranslation();

return (
<div className="fixed inset-x-0 bottom-0 border-t border-border bg-background/95 backdrop-blur z-20">
<div className="mx-auto flex max-w-3xl items-center justify-end gap-2 px-6 py-3">
<Button variant="outline" onClick={() => onSave(false)}>
{t("common.save")}
<span className="ml-1 text-xs text-muted-foreground opacity-80">(⌘S)</span>
</Button>
<Button onClick={() => onSave(true)}>
{t("editor.saveAndOpen")}
</Button>
</div>
</div>
);
}
93 changes: 93 additions & 0 deletions src/components/editor/ProjectBasicInfoSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { IdeKind, Project } from "@/lib/types";
import { IDE_OPTIONS } from "@/lib/types";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";

const isMac =
typeof navigator !== "undefined" && /Mac/i.test(navigator.userAgent);

interface ProjectBasicInfoSectionProps {
project: Project;
onUpdate: (patch: Partial<Project>) => void;
onActivateTerminal: () => void;
}

export function ProjectBasicInfoSection({
project,
onUpdate,
onActivateTerminal,
}: ProjectBasicInfoSectionProps) {
const { t } = useTranslation();

return (
<div className="flex flex-col gap-4">
<div className="flex items-end gap-4">
<div className="flex-1 space-y-1.5">
<Label htmlFor="project-name">{t("editor.name")}</Label>
<Input
id="project-name"
value={project.name}
onChange={(e) => onUpdate({ name: e.target.value })}
/>
</div>
<div className="w-44 space-y-1.5">
<Label htmlFor="project-group">{t("editor.group")}</Label>
<Input
id="project-group"
value={project.group ?? ""}
onChange={(e) =>
onUpdate({ group: e.target.value.trim() ? e.target.value : null })
}
placeholder={t("editor.groupPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label>{t("editor.openWith")}</Label>
<Select
value={project.ide}
onValueChange={(ide: IdeKind) => onUpdate({ ide })}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="VS Code" />
</SelectTrigger>
<SelectContent>
{IDE_OPTIONS.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">{t("editor.comingSoon")}</p>
{project.ide === "terminal" && isMac && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3.5 py-2.5 text-xs">
<span className="text-muted-foreground">
{t("editor.terminalMacNotice")}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 text-xs"
onClick={onActivateTerminal}
>
<ExternalLink className="size-3" />
{t("editor.activateTerminal")}
</Button>
</div>
)}
</div>
);
}
Loading
Loading