blob: 4367bef685d2c864056e438f5bee0d37d61b747d [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { ChangeEvent, useCallback, useEffect, useState } from "react";
2import { Project, useProjectId, useStateStore } from "./lib/state";
3import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./components/ui/select";
gio5f2f1002025-03-20 18:38:48 +04004import { Input } from "./components/ui/input";
5import { Button } from "./components/ui/button";
gio74ab7852025-05-13 13:19:31 +00006import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./components/ui/dialog";
gio5f2f1002025-03-20 18:38:48 +04007import { useToast } from "@/hooks/use-toast";
giobc47f9f2025-05-12 08:31:07 +00008import { Separator } from "./components/ui/separator";
9import { Plus } from "lucide-react";
gio74ab7852025-05-13 13:19:31 +000010import { z } from "zod";
11
12const createNewSchema = z.object({
13 id: z.string().min(1),
14});
gio5f2f1002025-03-20 18:38:48 +040015
gio880de162025-05-11 07:26:00 +000016export function ProjectSelect() {
giod0026612025-05-08 13:00:36 +000017 const { toast } = useToast();
18 const store = useStateStore();
19 const [projects, setProjects] = useState<Project[]>([]);
gio7461e502025-05-12 10:11:55 +000020 const projectId = useProjectId();
gio74ab7852025-05-13 13:19:31 +000021 // Track the selected project ID locally to ensure UI consistency
22 const [selectedId, setSelectedId] = useState<string | undefined>(projectId);
gio7461e502025-05-12 10:11:55 +000023
gio74ab7852025-05-13 13:19:31 +000024 // Keep local state in sync with global state
gio7461e502025-05-12 10:11:55 +000025 useEffect(() => {
gio74ab7852025-05-13 13:19:31 +000026 setSelectedId(projectId);
27 }, [projectId]);
28
29 const refreshProjects = useCallback(
30 async (id?: string) => {
31 console.log("refreshProjects", id);
32 try {
33 const resp = await fetch("/api/project");
34 const projectList = await resp.json();
35 const sortedProjects = [...projectList].sort((a, b) =>
36 a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
37 );
38 setProjects(sortedProjects);
39 if (id && !sortedProjects.some((p) => p.id === id)) {
40 throw new Error("MUST NOT REACH!");
41 }
42 if (id == null) {
43 id = sortedProjects[0].id;
44 }
45 if (id !== selectedId) {
46 setSelectedId(id);
47 store.setProject(id);
48 }
49 } catch (e) {
50 console.log(e);
51 }
52 },
53 [selectedId, store, setProjects, setSelectedId],
54 );
55
giod0026612025-05-08 13:00:36 +000056 useEffect(() => {
57 refreshProjects();
gio74ab7852025-05-13 13:19:31 +000058 });
gio7461e502025-05-12 10:11:55 +000059
giod0026612025-05-08 13:00:36 +000060 const [createNewOpen, setCreateNewOpen] = useState(false);
61 const onSelect = useCallback(
62 (projectId: string) => {
63 if (projectId === "create-new") {
64 setCreateNewOpen(true);
65 } else {
gio74ab7852025-05-13 13:19:31 +000066 setSelectedId(projectId);
giod0026612025-05-08 13:00:36 +000067 store.setProject(projectId);
68 }
69 },
gio74ab7852025-05-13 13:19:31 +000070 [store, setSelectedId],
giod0026612025-05-08 13:00:36 +000071 );
gio74ab7852025-05-13 13:19:31 +000072
giod0026612025-05-08 13:00:36 +000073 const [name, setName] = useState<string | undefined>(undefined);
74 const updateName = useCallback(
75 (e: ChangeEvent<HTMLInputElement>) => {
76 setName(e.target.value);
77 },
78 [setName],
79 );
gio74ab7852025-05-13 13:19:31 +000080
giod0026612025-05-08 13:00:36 +000081 const createNew = useCallback(() => {
giod0026612025-05-08 13:00:36 +000082 if (!name) {
gio74ab7852025-05-13 13:19:31 +000083 toast({
84 variant: "destructive",
85 title: "Name is required",
86 });
giod0026612025-05-08 13:00:36 +000087 return;
88 }
89 fetch("/api/project", {
90 method: "POST",
91 headers: {
92 "Content-Type": "application/json",
93 },
94 body: JSON.stringify({
95 name: name,
96 }),
97 })
98 .then(async (resp) => {
99 if (!resp.ok) {
gio74ab7852025-05-13 13:19:31 +0000100 return false;
101 }
102 const result = createNewSchema.safeParse(await resp.json());
103 if (!result.success) {
104 toast({
105 variant: "destructive",
106 title: `Failed to create project: ${name}`,
107 });
giod0026612025-05-08 13:00:36 +0000108 return;
109 }
gio74ab7852025-05-13 13:19:31 +0000110 const { id } = result.data;
111 await refreshProjects(id);
giod0026612025-05-08 13:00:36 +0000112 setCreateNewOpen(false);
gio74ab7852025-05-13 13:19:31 +0000113 setName(undefined); // Clear the input for next time
giod0026612025-05-08 13:00:36 +0000114 toast({
115 title: `Created project: ${name}`,
116 });
117 })
118 .catch((e) => {
119 console.log(e);
120 toast({
121 variant: "destructive",
122 title: `Failed to create project: ${name}`,
123 });
124 });
gio74ab7852025-05-13 13:19:31 +0000125 }, [name, setCreateNewOpen, toast, refreshProjects]);
126
giod0026612025-05-08 13:00:36 +0000127 return (
gio74ab7852025-05-13 13:19:31 +0000128 <>
129 <Select onValueChange={onSelect} value={selectedId}>
130 <SelectTrigger className="w-[200px] !border-none !shadow-none !focus:ring-0 !focus:ring-offset-0">
131 <SelectValue placeholder="Choose Project" />
132 </SelectTrigger>
133 <SelectContent>
134 {projects.map((p) => (
135 <SelectItem key={p.id} value={p.id}>
136 {p.name}
137 </SelectItem>
138 ))}
139 <Separator />
140 <SelectItem value={"create-new"}>
141 <div className="flex flex-row items-center gap-1">
giobc47f9f2025-05-12 08:31:07 +0000142 <Plus />
gio74ab7852025-05-13 13:19:31 +0000143 <div>New project</div>
144 </div>
145 </SelectItem>
146 </SelectContent>
147 </Select>
148 <Dialog open={createNewOpen} onOpenChange={setCreateNewOpen}>
149 <DialogContent>
150 <DialogHeader>
151 <DialogTitle>New project</DialogTitle>
152 </DialogHeader>
153 <Input
154 type="text"
155 placeholder="Name"
156 onChange={updateName}
157 value={name || ""}
158 onKeyDown={(e) => {
159 if (e.key === "Enter") {
160 createNew();
161 }
162 }}
163 />
164 <DialogFooter>
165 <Button onClick={createNew}>Create</Button>
166 </DialogFooter>
167 </DialogContent>
168 </Dialog>
169 </>
giod0026612025-05-08 13:00:36 +0000170 );
171}