diff options
| -rw-r--r-- | packages/app/src/components/dialog-edit-project.tsx | 180 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 1 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 12 |
3 files changed, 190 insertions, 3 deletions
diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx new file mode 100644 index 000000000..27ce3903c --- /dev/null +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -0,0 +1,180 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Icon } from "@opencode-ai/ui/icon" +import { createMemo, createSignal, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobalSDK } from "@/context/global-sdk" +import { type LocalProject, getAvatarColors } from "@/context/layout" +import { Avatar } from "@opencode-ai/ui/avatar" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +function getFilename(input: string) { + const parts = input.split("/") + return parts[parts.length - 1] || input +} + +export function DialogEditProject(props: { project: LocalProject }) { + const dialog = useDialog() + const globalSDK = useGlobalSDK() + + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color || "pink", + iconUrl: props.project.icon?.url || "", + saving: false, + }) + + const [dragOver, setDragOver] = createSignal(false) + + function handleFileSelect(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => setStore("iconUrl", e.target?.result as string) + reader.readAsDataURL(file) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer?.files[0] + if (file) handleFileSelect(file) + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + setDragOver(true) + } + + function handleDragLeave() { + setDragOver(false) + } + + function handleInputChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) handleFileSelect(file) + } + + function clearIcon() { + setStore("iconUrl", "") + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (!props.project.id) return + + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + await globalSDK.client.project.update({ + projectID: props.project.id, + name, + icon: { color: store.color, url: store.iconUrl }, + }) + setStore("saving", false) + dialog.close() + } + + return ( + <Dialog title="Edit project"> + <form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3"> + <div class="flex flex-col gap-4"> + <TextField + autofocus + type="text" + label="Name" + placeholder={folderName()} + value={store.name} + onChange={(v) => setStore("name", v)} + /> + + <div class="flex flex-col gap-2"> + <label class="text-12-medium text-text-weak">Icon</label> + <div class="flex gap-3 items-start"> + <div class="relative"> + <div + class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer" + classList={{ + "border-text-interactive-base bg-surface-info-base/20": dragOver(), + "border-border-base hover:border-border-strong": !dragOver(), + }} + onDrop={handleDrop} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onClick={() => document.getElementById("icon-upload")?.click()} + > + <Show + when={store.iconUrl} + fallback={ + <div class="size-full flex items-center justify-center"> + <Avatar + fallback={store.name || defaultName()} + {...getAvatarColors(store.color)} + class="size-full" + /> + </div> + } + > + <img src={store.iconUrl} alt="Project icon" class="size-full object-cover" /> + </Show> + </div> + <Show when={store.iconUrl}> + <button + type="button" + class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover" + onClick={clearIcon} + > + <Icon name="close" class="size-3 text-icon-base" /> + </button> + </Show> + </div> + <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} /> + <div class="flex flex-col gap-1.5 text-12-regular text-text-weak"> + <span>Click or drag an image</span> + <span>Recommended: 128x128px</span> + </div> + </div> + </div> + + <Show when={!store.iconUrl}> + <div class="flex flex-col gap-2"> + <label class="text-12-medium text-text-weak">Color</label> + <div class="flex gap-2"> + <For each={AVATAR_COLOR_KEYS}> + {(color) => ( + <button + type="button" + class="relative size-8 rounded-md transition-all" + classList={{ + "ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base": + store.color === color, + }} + style={{ background: getAvatarColors(color).background }} + onClick={() => setStore("color", color)} + > + <Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" /> + </button> + )} + </For> + </div> + </div> + </Show> + </div> + + <div class="flex justify-end gap-2"> + <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}> + Cancel + </Button> + <Button type="submit" variant="primary" size="large" disabled={store.saving}> + {store.saving ? "Saving..." : "Save"} + </Button> + </div> + </form> + </Dialog> + ) +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index c6ba5fef5..4ccab98e3 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -70,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( { ...project, ...(metadata ?? {}), + icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, }, ] } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2bc0c3131..0b9178948 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,6 +49,7 @@ import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { DialogEditProject } from "@/components/dialog-edit-project" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" @@ -522,7 +523,7 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -558,7 +559,7 @@ export default function Layout(props: ParentProps) { } const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const current = createMemo(() => base64Decode(params.dir ?? "")) return ( <Switch> @@ -701,7 +702,7 @@ export default function Layout(props: ParentProps) { const sortable = createSortable(props.project.worktree) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const slug = createMemo(() => base64Encode(props.project.worktree)) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const [store, setProjectStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session.toSorted(sortSessions)) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) @@ -747,6 +748,11 @@ export default function Layout(props: ParentProps) { <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" /> <DropdownMenu.Portal> <DropdownMenu.Content> + <DropdownMenu.Item + onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)} + > + <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel> + </DropdownMenu.Item> <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}> <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel> </DropdownMenu.Item> |
