Split project-type setup into pluggable registry; add WordPress admin link and delete flow
@@ -0,0 +1,173 @@
|
|||||||
|
# Aurora Dockside — Build Session Log
|
||||||
|
|
||||||
|
A record of the conversation that produced this project: an Electron + React +
|
||||||
|
TypeScript desktop GUI for managing [DDEV](https://ddev.com) local development
|
||||||
|
environments, built as an independent clone of
|
||||||
|
[DDEV Manager](https://github.com/DDEV-Manager/ddev-manager) (Tauri + React + Rust).
|
||||||
|
|
||||||
|
## Origin
|
||||||
|
|
||||||
|
The session started with an unrelated request — install and configure DDEV
|
||||||
|
itself (Homebrew, OrbStack as the container runtime, mkcert for trusted local
|
||||||
|
HTTPS). Partway through, the idea came up: rather than using the existing
|
||||||
|
DDEV Manager app, build an equivalent from scratch, in our own code.
|
||||||
|
|
||||||
|
### Tech stack decisions (asked up front)
|
||||||
|
|
||||||
|
- **Framework**: Electron + React + TypeScript, chosen over matching the
|
||||||
|
original's Tauri/Rust stack — no Rust toolchain required.
|
||||||
|
- **Scope**: Full feature parity with DDEV Manager, built incrementally
|
||||||
|
(each step runnable/testable) rather than scaffolding everything blind.
|
||||||
|
- **Package manager**: pnpm, enabled via `corepack enable`.
|
||||||
|
- **Name**: Aurora Dockside (chosen by the user over a couple of suggested
|
||||||
|
alternatives).
|
||||||
|
|
||||||
|
A formal plan was written (Plan Mode) covering architecture (Electron main
|
||||||
|
process owns all `ddev` CLI execution; typed `window.api` surface via
|
||||||
|
contextBridge; TanStack Query + Zustand on the renderer side) and a 9-step
|
||||||
|
build order before any code was written.
|
||||||
|
|
||||||
|
## Build steps (each committed separately, each verified against a real
|
||||||
|
running DDEV project — not just typechecked)
|
||||||
|
|
||||||
|
1. **Scaffold** — `electron-vite` + React 19 + TypeScript template via
|
||||||
|
`@quick-start/create-electron`, git-initialized on `main`.
|
||||||
|
2. **Tailwind CSS 4, Vitest, core libraries** — Tailwind via the Vite
|
||||||
|
plugin, Vitest + React Testing Library, Zustand, TanStack Query,
|
||||||
|
Lucide icons. Stripped the electron-vite demo boilerplate.
|
||||||
|
3. **Core project management** — `ddev.ts` CLI wrapper (execFile +
|
||||||
|
JSON envelope parsing), IPC handlers, typed preload API, TanStack Query
|
||||||
|
hooks, two-pane UI (project list + detail panel). Includes a PATH
|
||||||
|
fallback in the main process, since GUI apps launched outside a
|
||||||
|
terminal don't inherit the shell's PATH and can't find Homebrew's
|
||||||
|
`ddev` binary otherwise.
|
||||||
|
4. **Terminal panel, status bar, toasts** — long-running commands
|
||||||
|
(start/stop/restart) now spawn via a `commandRunner.ts` module and
|
||||||
|
stream stdout/stderr to the renderer over IPC instead of waiting for
|
||||||
|
the whole command to finish. Cancel support kills the tracked child
|
||||||
|
process by operation id.
|
||||||
|
5. **Database tools** — snapshot create/list/restore/delete, DB
|
||||||
|
import/export via native file dialogs. `ddev snapshot restore` has no
|
||||||
|
project-name flag (unlike other ddev commands), so it runs with
|
||||||
|
`cwd` set to the project's approot instead.
|
||||||
|
6. **Add-on management** — registry browser (~270 third-party add-ons)
|
||||||
|
with search, install/remove via `ddev add-on get/remove`. Verified
|
||||||
|
working with the project stopped.
|
||||||
|
7. **Log viewer** — `ddev logs -f` streamed over a dedicated IPC channel
|
||||||
|
(decoupled from the terminal/status-bar model, since log tailing runs
|
||||||
|
indefinitely rather than completing). Service switcher, text filter.
|
||||||
|
8. **Project creation wizard** — native directory picker, project name,
|
||||||
|
project type selector, optional docroot, streamed `ddev config`.
|
||||||
|
9. **Settings** — theme (light/dark/system, persisted), zoom controls via
|
||||||
|
a small main-process IPC (`webContents.setZoomLevel`), keyboard
|
||||||
|
shortcuts (Cmd/Ctrl+N, +comma, +=/-/0).
|
||||||
|
10. **Packaging** — cleaned up `electron-builder.yml` (correct appId/
|
||||||
|
productName, removed irrelevant camera/mic/Documents/Downloads
|
||||||
|
Info.plist entries, removed a placeholder auto-update publish
|
||||||
|
config). Verified with a real `pnpm build:unpack` run, launching the
|
||||||
|
packaged (unsigned, no Developer ID cert available) `.app` directly
|
||||||
|
and confirming it could still find and run `ddev`.
|
||||||
|
|
||||||
|
Scoped down from full parity, on purpose: composer/wp-cli-based CMS
|
||||||
|
scaffolding for Drupal/Laravel/Shopware, and auto-update infrastructure.
|
||||||
|
|
||||||
|
## Bugs found via live testing (not just code review)
|
||||||
|
|
||||||
|
Every feature was verified by actually driving the running app — mostly via
|
||||||
|
a small CDP (Chrome DevTools Protocol) driver script that clicked real
|
||||||
|
buttons in the real Electron window, since a lot of this app's correctness
|
||||||
|
depends on real subprocess/IPC/timing behavior that static review can't
|
||||||
|
catch. This caught several real defects:
|
||||||
|
|
||||||
|
- **`window.prompt()` doesn't work in Electron's renderer** — it returns
|
||||||
|
`null` immediately with no dialog, unlike `window.confirm()` which does
|
||||||
|
show a real native dialog. The snapshot-naming UI was rewritten to use
|
||||||
|
an inline text input instead.
|
||||||
|
- **`ddev addon list --installed` omits the `raw` JSON key entirely** when
|
||||||
|
nothing is installed (unlike `ddev list`/`ddev snapshot --list`, which
|
||||||
|
include `raw: null`). The shared JSON-parsing helper treated a missing
|
||||||
|
`raw` as an error, so after removing the last add-on the query would
|
||||||
|
error on refetch and React Query kept showing the stale cached row.
|
||||||
|
Fixed by splitting the helper into a strict variant (for `describe`,
|
||||||
|
where missing data really is an error) and a lenient list variant.
|
||||||
|
- **Log filtering operated on raw stream chunks, not lines** — a single
|
||||||
|
chunk of stdout/stderr can bundle many lines or split one across chunk
|
||||||
|
boundaries, so filtering by chunk let unrelated lines through. Fixed by
|
||||||
|
buffering partial lines per stream and only filtering once full lines
|
||||||
|
are assembled.
|
||||||
|
- **A `react-hooks/set-state-in-effect` lint violation** in the log-stream
|
||||||
|
hook, from resetting state synchronously inside an effect body. Fixed
|
||||||
|
by keying the streaming component by `service` so switching services
|
||||||
|
remounts it — state resets via fresh `useState` initializers instead,
|
||||||
|
which is the React-recommended pattern for this.
|
||||||
|
|
||||||
|
## Post-launch bug reports and fixes
|
||||||
|
|
||||||
|
After the initial 9-step build was declared done, real usage surfaced three
|
||||||
|
more gaps:
|
||||||
|
|
||||||
|
1. **"I created a new container for wordpress, only wp-content was
|
||||||
|
created."** — `ddev config --project-type=wordpress` only scaffolds the
|
||||||
|
DDEV-managed `wp-config.php` bridge and `wp-content/uploads`; it never
|
||||||
|
downloads WordPress core (`wp-admin/`, `wp-includes/`, `index.php`,
|
||||||
|
etc.). Fixed the user's existing project directly (`ddev wp core
|
||||||
|
download`), then added the missing step to the wizard.
|
||||||
|
2. **"Now I get 403 Forbidden. Shouldn't we have an option to set admin
|
||||||
|
username, password, and email while setting it up?"** — right call:
|
||||||
|
`wp core download` only fetches files, `wp core install` is what
|
||||||
|
actually creates the database tables and admin user. The wizard's
|
||||||
|
WordPress path now chains `configure → start → wp core download →
|
||||||
|
wp core install`, with site title/admin username/password/email
|
||||||
|
fields shown only for that project type. wp-cli needs the containers
|
||||||
|
running, so this path always starts the project regardless of the
|
||||||
|
generic "start after creating" checkbox.
|
||||||
|
3. **"What about the ability to delete the site? I can create all day
|
||||||
|
long but not able to delete them from the software."** — a real gap;
|
||||||
|
the original DDEV Manager has this and it hadn't been built yet. Added
|
||||||
|
a Delete button (`ddev delete <name> --yes`, keeping ddev's default
|
||||||
|
database snapshot as a safety net) with a confirmation dialog that
|
||||||
|
clarifies it only removes DDEV's registration/containers/database, not
|
||||||
|
the project's files on disk.
|
||||||
|
4. Also added, per a follow-up request: a **WP Admin quick-link** button
|
||||||
|
for running WordPress-type projects, opening `{primary_url}/wp-admin/`
|
||||||
|
directly.
|
||||||
|
|
||||||
|
All three were verified end-to-end against a fresh throwaway project:
|
||||||
|
full configure→start→download→install chain producing a genuinely working
|
||||||
|
site (200 on the homepage, correct login redirect on `/wp-admin`), and
|
||||||
|
delete actually removing the project from `ddev list`. Two apparent bugs
|
||||||
|
that came up during that verification turned out to be the *test script*
|
||||||
|
reading DOM state before React had re-rendered, or before ddev's
|
||||||
|
multi-step delete (build + start + snapshot + teardown) had actually
|
||||||
|
finished — not real defects.
|
||||||
|
|
||||||
|
## Final artifact
|
||||||
|
|
||||||
|
A packaged, distributable build was produced on request:
|
||||||
|
|
||||||
|
- `dist/aurora-dockside-1.0.0.dmg` — installer
|
||||||
|
- `dist/Aurora Dockside-1.0.0-arm64-mac.zip` — zipped `.app`
|
||||||
|
|
||||||
|
Both unsigned (no Developer ID certificate on this machine) — macOS
|
||||||
|
Gatekeeper requires right-click → Open on first launch.
|
||||||
|
|
||||||
|
## Repository state
|
||||||
|
|
||||||
|
10 commits on `main`, one per build step plus the post-launch fixes:
|
||||||
|
|
||||||
|
```
|
||||||
|
Scaffold Aurora Dockside with electron-vite + React + TypeScript
|
||||||
|
Wire up Tailwind CSS 4, Vitest, and core app libraries
|
||||||
|
Add core DDEV project management (list/describe/start/stop/restart)
|
||||||
|
Add streaming terminal panel, status bar, and toast notifications
|
||||||
|
Add database tools: snapshots and import/export (step 4)
|
||||||
|
Add add-on management: registry browser, install, remove (step 5)
|
||||||
|
Add streaming log viewer with service switching and filtering (step 6)
|
||||||
|
Add project creation wizard (step 7)
|
||||||
|
Add settings: theme, zoom controls, keyboard shortcuts (step 8)
|
||||||
|
Finalize packaging config and verify a real build (step 9)
|
||||||
|
Fix WordPress scaffolding gap; add delete project + WP admin link
|
||||||
|
```
|
||||||
|
|
||||||
|
*(Note: some project files show further edits beyond this log's cutoff —
|
||||||
|
work continued in the project after this session.)*
|
||||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 356 KiB |
@@ -34,8 +34,13 @@ export function registerCreateIpc(): void {
|
|||||||
// the web container. Kept as two separate tracked operations rather than
|
// the web container. Kept as two separate tracked operations rather than
|
||||||
// one combined command so each phase's terminal:exit event correctly owns
|
// one combined command so each phase's terminal:exit event correctly owns
|
||||||
// its own status-bar/toast lifecycle (see useCreateProject.ts).
|
// its own status-bar/toast lifecycle (see useCreateProject.ts).
|
||||||
ipcMain.handle('create:downloadWordpress', (event, operationId: string, directory: string) =>
|
ipcMain.handle(
|
||||||
runStreamed(operationId, ['wp', 'core', 'download'], event.sender, { cwd: directory })
|
'create:downloadWordpress',
|
||||||
|
(event, operationId: string, directory: string, locale: string) => {
|
||||||
|
const args = ['wp', 'core', 'download']
|
||||||
|
if (locale.trim() && locale.trim() !== 'en_US') args.push(`--locale=${locale.trim()}`)
|
||||||
|
return runStreamed(operationId, args, event.sender, { cwd: directory })
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -48,23 +53,22 @@ export function registerCreateIpc(): void {
|
|||||||
title: string,
|
title: string,
|
||||||
adminUser: string,
|
adminUser: string,
|
||||||
adminPassword: string,
|
adminPassword: string,
|
||||||
adminEmail: string
|
adminEmail: string,
|
||||||
) =>
|
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||||
runStreamed(
|
) => {
|
||||||
operationId,
|
const args = [
|
||||||
[
|
|
||||||
'wp',
|
'wp',
|
||||||
'core',
|
'core',
|
||||||
'install',
|
multisite === 'none' ? 'install' : 'multisite-install',
|
||||||
`--url=${siteUrl}`,
|
`--url=${siteUrl}`,
|
||||||
`--title=${title}`,
|
`--title=${title}`,
|
||||||
`--admin_user=${adminUser}`,
|
`--admin_user=${adminUser}`,
|
||||||
`--admin_password=${adminPassword}`,
|
`--admin_password=${adminPassword}`,
|
||||||
`--admin_email=${adminEmail}`,
|
`--admin_email=${adminEmail}`,
|
||||||
'--skip-email'
|
'--skip-email'
|
||||||
],
|
]
|
||||||
event.sender,
|
if (multisite === 'subdomain') args.push('--subdomains')
|
||||||
{ cwd: directory }
|
return runStreamed(operationId, args, event.sender, { cwd: directory })
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ export function registerProjectsIpc(): void {
|
|||||||
ipcMain.handle('projects:stop', (event, operationId: string, name: string) =>
|
ipcMain.handle('projects:stop', (event, operationId: string, name: string) =>
|
||||||
runStreamed(operationId, ['stop', name], event.sender)
|
runStreamed(operationId, ['stop', name], event.sender)
|
||||||
)
|
)
|
||||||
|
// `-y` matters here beyond convenience: we spawn `ddev` with no stdin
|
||||||
|
// wired up (see commandRunner.ts), so any confirmation prompt ddev tries
|
||||||
|
// to show — e.g. when a database type change requires it — would hang the
|
||||||
|
// operation forever with no way for the user to answer it.
|
||||||
ipcMain.handle('projects:restart', (event, operationId: string, name: string) =>
|
ipcMain.handle('projects:restart', (event, operationId: string, name: string) =>
|
||||||
runStreamed(operationId, ['restart', name], event.sender)
|
runStreamed(operationId, ['restart', name, '-y'], event.sender)
|
||||||
)
|
)
|
||||||
// Removes DDEV's project registration + containers + database (auto-
|
// Removes DDEV's project registration + containers + database (auto-
|
||||||
// snapshotted first, unless omitted) — does not touch the project's files
|
// snapshotted first, unless omitted) — does not touch the project's files
|
||||||
@@ -20,4 +24,33 @@ export function registerProjectsIpc(): void {
|
|||||||
ipcMain.handle('projects:delete', (event, operationId: string, name: string) =>
|
ipcMain.handle('projects:delete', (event, operationId: string, name: string) =>
|
||||||
runStreamed(operationId, ['delete', name, '--yes'], event.sender)
|
runStreamed(operationId, ['delete', name, '--yes'], event.sender)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Reconfigures a project's PHP version, web server, database, or Xdebug
|
||||||
|
// state via `ddev config` (writes .ddev/config.yaml) — callers are
|
||||||
|
// expected to follow a successful call with a restart (if the project is
|
||||||
|
// running) to actually apply it, same two-phase pattern as create.ts.
|
||||||
|
ipcMain.handle(
|
||||||
|
'projects:updateEnvironment',
|
||||||
|
(
|
||||||
|
event,
|
||||||
|
operationId: string,
|
||||||
|
name: string,
|
||||||
|
approot: string,
|
||||||
|
updates: {
|
||||||
|
phpVersion?: string
|
||||||
|
webserverType?: string
|
||||||
|
database?: string
|
||||||
|
xdebugEnabled?: boolean
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const args = ['config', `--project-name=${name}`]
|
||||||
|
if (updates.phpVersion) args.push(`--php-version=${updates.phpVersion}`)
|
||||||
|
if (updates.webserverType) args.push(`--webserver-type=${updates.webserverType}`)
|
||||||
|
if (updates.database) args.push(`--database=${updates.database}`)
|
||||||
|
if (updates.xdebugEnabled !== undefined) {
|
||||||
|
args.push(`--xdebug-enabled=${updates.xdebugEnabled}`)
|
||||||
|
}
|
||||||
|
return runStreamed(operationId, args, event.sender, { cwd: approot })
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
DdevProjectDetail,
|
DdevProjectDetail,
|
||||||
DdevProjectSummary,
|
DdevProjectSummary,
|
||||||
DdevSnapshot,
|
DdevSnapshot,
|
||||||
|
EnvironmentUpdate,
|
||||||
LogDataEvent,
|
LogDataEvent,
|
||||||
LogExitEvent,
|
LogExitEvent,
|
||||||
TerminalDataEvent,
|
TerminalDataEvent,
|
||||||
@@ -25,7 +26,14 @@ const api = {
|
|||||||
restart: (operationId: string, name: string): Promise<void> =>
|
restart: (operationId: string, name: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('projects:restart', operationId, name),
|
ipcRenderer.invoke('projects:restart', operationId, name),
|
||||||
delete: (operationId: string, name: string): Promise<void> =>
|
delete: (operationId: string, name: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('projects:delete', operationId, name)
|
ipcRenderer.invoke('projects:delete', operationId, name),
|
||||||
|
updateEnvironment: (
|
||||||
|
operationId: string,
|
||||||
|
name: string,
|
||||||
|
approot: string,
|
||||||
|
updates: EnvironmentUpdate
|
||||||
|
): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('projects:updateEnvironment', operationId, name, approot, updates)
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
cancel: (operationId: string): Promise<boolean> =>
|
cancel: (operationId: string): Promise<boolean> =>
|
||||||
@@ -99,8 +107,8 @@ const api = {
|
|||||||
projectType,
|
projectType,
|
||||||
docroot
|
docroot
|
||||||
),
|
),
|
||||||
downloadWordpress: (operationId: string, directory: string): Promise<void> =>
|
downloadWordpress: (operationId: string, directory: string, locale: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('create:downloadWordpress', operationId, directory),
|
ipcRenderer.invoke('create:downloadWordpress', operationId, directory, locale),
|
||||||
setupWordpress: (
|
setupWordpress: (
|
||||||
operationId: string,
|
operationId: string,
|
||||||
directory: string,
|
directory: string,
|
||||||
@@ -108,7 +116,8 @@ const api = {
|
|||||||
title: string,
|
title: string,
|
||||||
adminUser: string,
|
adminUser: string,
|
||||||
adminPassword: string,
|
adminPassword: string,
|
||||||
adminEmail: string
|
adminEmail: string,
|
||||||
|
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
'create:setupWordpress',
|
'create:setupWordpress',
|
||||||
@@ -118,7 +127,8 @@ const api = {
|
|||||||
title,
|
title,
|
||||||
adminUser,
|
adminUser,
|
||||||
adminPassword,
|
adminPassword,
|
||||||
adminEmail
|
adminEmail,
|
||||||
|
multisite
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
zoom: {
|
zoom: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Plus, Settings } from 'lucide-react'
|
import { Anchor, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
|
||||||
import { ProjectDetail } from './components/projects/ProjectDetail'
|
import { ProjectDetail } from './components/projects/ProjectDetail'
|
||||||
import { ProjectList } from './components/projects/ProjectList'
|
import { ProjectList } from './components/projects/ProjectList'
|
||||||
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
||||||
@@ -11,6 +11,7 @@ import { useAppStore } from './stores/appStore'
|
|||||||
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
||||||
import { useAppliedTheme } from './hooks/useAppliedTheme'
|
import { useAppliedTheme } from './hooks/useAppliedTheme'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
import docksideIcon from './assets/dockside-icon.png'
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||||
@@ -24,17 +25,29 @@ function App(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
|
<div className="flex h-screen w-screen flex-col bg-[linear-gradient(135deg,rgba(8,145,178,0.12)_0%,transparent_36%),linear-gradient(180deg,#f8fafc_0%,#eef6f5_52%,#e7eef5_100%)] text-neutral-900 dark:bg-[linear-gradient(135deg,rgba(45,212,191,0.10)_0%,transparent_36%),linear-gradient(180deg,#070a0f_0%,#0f172a_54%,#092f34_100%)] dark:text-neutral-100">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
|
<aside className="flex w-80 flex-shrink-0 flex-col border-r border-white/70 bg-white/[0.78] shadow-[8px_0_30px_rgba(15,23,42,0.06)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.72] dark:shadow-black/25">
|
||||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
<div className="flex items-center justify-between border-b border-neutral-200/70 px-4 py-3 dark:border-white/10">
|
||||||
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={docksideIcon}
|
||||||
|
alt=""
|
||||||
|
className="size-10 rounded-xl shadow-sm shadow-cyan-900/20"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-sm font-semibold tracking-wide">Aurora Dockside</h1>
|
||||||
|
<p className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
DDEV command deck
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCreateOpen(true)}
|
onClick={() => setIsCreateOpen(true)}
|
||||||
title="New Project"
|
title="New Project"
|
||||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-cyan-50 hover:text-cyan-700 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-300"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -42,7 +55,7 @@ function App(): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||||
>
|
>
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -56,8 +69,55 @@ function App(): React.JSX.Element {
|
|||||||
{selectedProjectName ? (
|
{selectedProjectName ? (
|
||||||
<ProjectDetail name={selectedProjectName} />
|
<ProjectDetail name={selectedProjectName} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-neutral-500">
|
<div className="flex h-full items-center justify-center p-8">
|
||||||
Select a project to see its details.
|
<div className="relative grid w-full max-w-3xl overflow-hidden rounded-2xl border border-white/70 bg-white/[0.84] shadow-[0_24px_80px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.70] dark:shadow-black/30">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(8,145,178,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(8,145,178,0.08)_1px,transparent_1px)] bg-[size:34px_34px] dark:bg-[linear-gradient(rgba(45,212,191,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.08)_1px,transparent_1px)]" />
|
||||||
|
<div className="relative grid gap-7 p-8">
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-cyan-200 bg-cyan-50 px-3 py-1 text-xs font-medium text-cyan-800 dark:border-cyan-400/20 dark:bg-cyan-400/10 dark:text-cyan-200">
|
||||||
|
<Sparkles size={13} />
|
||||||
|
Local environments, neatly handled
|
||||||
|
</div>
|
||||||
|
<h2 className="max-w-xl text-3xl font-semibold leading-tight text-neutral-950 dark:text-white">
|
||||||
|
Your DDEV projects deserve a better cockpit.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-xl text-sm leading-6 text-neutral-600 dark:text-neutral-300">
|
||||||
|
Choose a project from the sidebar to manage lifecycle actions, URLs, logs,
|
||||||
|
database snapshots, and add-ons from one polished workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={docksideIcon}
|
||||||
|
alt=""
|
||||||
|
className="hidden size-24 flex-shrink-0 rounded-3xl shadow-lg shadow-cyan-900/20 sm:block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||||
|
<FolderOpen size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||||
|
<p className="text-sm font-semibold">Project overview</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||||
|
Status, stack details, paths, and services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||||
|
<TerminalSquare size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||||
|
<p className="text-sm font-semibold">Live operations</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||||
|
Start, stop, restart, and inspect logs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||||
|
<Anchor size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||||
|
<p className="text-sm font-semibold">Database control</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||||
|
Snapshots, imports, exports, and add-ons.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 371 KiB |
@@ -3,7 +3,12 @@
|
|||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
margin: 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-feature-settings:
|
||||||
|
'liga' 1,
|
||||||
|
'calt' 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
@@ -16,3 +21,41 @@ code {
|
|||||||
Liberation Mono,
|
Liberation Mono,
|
||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(:disabled),
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid #06b6d4;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(100, 116, 139, 0.35);
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(100, 116, 139, 0.55);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,56 +1,63 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { FolderOpen, X } from 'lucide-react'
|
import { clsx } from 'clsx'
|
||||||
import {
|
import {
|
||||||
useCreateProject,
|
ArrowLeft,
|
||||||
useDownloadWordpress,
|
ArrowRight,
|
||||||
useSetupWordpress
|
Boxes,
|
||||||
} from '../../hooks/useCreateProject'
|
Check,
|
||||||
import { useStartProject } from '../../hooks/useDdev'
|
FileCode2,
|
||||||
|
FolderOpen,
|
||||||
|
Globe2,
|
||||||
|
Layers3,
|
||||||
|
Package,
|
||||||
|
Sparkles,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useCreateProject } from '../../hooks/useCreateProject'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
import { getTypeLabel, PROJECT_TYPES } from './types/registry'
|
||||||
|
import { GenericSetup } from './types/GenericSetup'
|
||||||
|
import { WordpressSetup } from './types/WordpressSetup'
|
||||||
|
import type { TypeSetupHandle } from './types/shared'
|
||||||
|
import docksideIcon from '../../assets/dockside-icon.png'
|
||||||
|
|
||||||
const PROJECT_TYPES = [
|
type Step = 'site' | 'setup'
|
||||||
{ value: '', label: 'Auto-detect' },
|
|
||||||
{ value: 'php', label: 'PHP (generic)' },
|
const fieldClass =
|
||||||
{ value: 'wordpress', label: 'WordPress' },
|
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||||
{ value: 'drupal', label: 'Drupal' },
|
|
||||||
{ value: 'laravel', label: 'Laravel' },
|
const labelClass =
|
||||||
{ value: 'backdrop', label: 'Backdrop' },
|
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||||
{ value: 'craftcms', label: 'Craft CMS' },
|
|
||||||
{ value: 'magento2', label: 'Magento 2' },
|
const TYPE_ICONS: Record<string, typeof Globe2> = {
|
||||||
{ value: 'shopware6', label: 'Shopware 6' },
|
'': Sparkles,
|
||||||
{ value: 'symfony', label: 'Symfony' },
|
php: FileCode2,
|
||||||
{ value: 'typo3', label: 'TYPO3' }
|
wordpress: Globe2,
|
||||||
]
|
drupal: Layers3,
|
||||||
|
laravel: FileCode2,
|
||||||
|
backdrop: Layers3,
|
||||||
|
craftcms: Package,
|
||||||
|
magento2: Package,
|
||||||
|
shopware6: Package,
|
||||||
|
symfony: Boxes,
|
||||||
|
typo3: Layers3
|
||||||
|
}
|
||||||
|
|
||||||
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||||
|
const [step, setStep] = useState<Step>('site')
|
||||||
const [directory, setDirectory] = useState<string | null>(null)
|
const [directory, setDirectory] = useState<string | null>(null)
|
||||||
const [projectName, setProjectName] = useState('')
|
const [projectName, setProjectName] = useState('')
|
||||||
const [projectType, setProjectType] = useState('')
|
const [projectType, setProjectType] = useState('')
|
||||||
const [docroot, setDocroot] = useState('')
|
const [docroot, setDocroot] = useState('')
|
||||||
const [startAfterCreate, setStartAfterCreate] = useState(true)
|
const [setupValid, setSetupValid] = useState(true)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [siteTitle, setSiteTitle] = useState('')
|
|
||||||
const [adminUser, setAdminUser] = useState('admin')
|
|
||||||
const [adminPassword, setAdminPassword] = useState('')
|
|
||||||
const [adminEmail, setAdminEmail] = useState('')
|
|
||||||
|
|
||||||
const createProject = useCreateProject()
|
const createProject = useCreateProject()
|
||||||
const startProject = useStartProject()
|
|
||||||
const downloadWordpress = useDownloadWordpress()
|
|
||||||
const setupWordpress = useSetupWordpress()
|
|
||||||
const selectProject = useAppStore((s) => s.selectProject)
|
const selectProject = useAppStore((s) => s.selectProject)
|
||||||
|
const setupRef = useRef<TypeSetupHandle>(null)
|
||||||
|
|
||||||
const isWordpress = projectType === 'wordpress'
|
const canContinue = directory !== null && projectName.trim().length > 0
|
||||||
const isSubmitting =
|
const canSubmit = canContinue && setupValid && !isSubmitting
|
||||||
createProject.isPending ||
|
|
||||||
startProject.isPending ||
|
|
||||||
downloadWordpress.isPending ||
|
|
||||||
setupWordpress.isPending
|
|
||||||
const canSubmit =
|
|
||||||
directory !== null &&
|
|
||||||
projectName.trim().length > 0 &&
|
|
||||||
!isSubmitting &&
|
|
||||||
(!isWordpress || (adminUser.trim() && adminPassword.trim() && adminEmail.trim()))
|
|
||||||
|
|
||||||
async function handlePickDirectory(): Promise<void> {
|
async function handlePickDirectory(): Promise<void> {
|
||||||
const picked = await window.api.create.pickDirectory()
|
const picked = await window.api.create.pickDirectory()
|
||||||
@@ -59,191 +66,238 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
if (!projectName) {
|
if (!projectName) {
|
||||||
const name = picked.split('/').filter(Boolean).pop() ?? ''
|
const name = picked.split('/').filter(Boolean).pop() ?? ''
|
||||||
setProjectName(name)
|
setProjectName(name)
|
||||||
if (!siteTitle) setSiteTitle(name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(): Promise<void> {
|
async function handleSubmit(): Promise<void> {
|
||||||
if (!directory) return
|
if (!directory || !canSubmit) return
|
||||||
const name = projectName.trim()
|
const name = projectName.trim()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot })
|
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot })
|
||||||
|
await setupRef.current?.runPostCreate({ directory, projectName: name })
|
||||||
if (isWordpress) {
|
|
||||||
// wp-cli needs the containers running, so this ignores the "start
|
|
||||||
// after creating" checkbox — an unstarted WordPress project would
|
|
||||||
// just be the same half-built state this whole flow exists to avoid.
|
|
||||||
await startProject.mutateAsync(name)
|
|
||||||
await downloadWordpress.mutateAsync({ directory })
|
|
||||||
await setupWordpress.mutateAsync({
|
|
||||||
directory,
|
|
||||||
siteUrl: `https://${name}.ddev.site`,
|
|
||||||
title: siteTitle.trim() || name,
|
|
||||||
adminUser: adminUser.trim(),
|
|
||||||
adminPassword: adminPassword.trim(),
|
|
||||||
adminEmail: adminEmail.trim()
|
|
||||||
})
|
|
||||||
} else if (startAfterCreate) {
|
|
||||||
startProject.mutate(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectProject(name)
|
selectProject(name)
|
||||||
onClose()
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-8 backdrop-blur-sm">
|
||||||
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
<div className="grid max-h-[86vh] w-full max-w-4xl overflow-hidden rounded-2xl border border-white/70 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950 md:grid-cols-[0.82fr_1.18fr]">
|
||||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
<aside className="relative hidden overflow-hidden bg-neutral-950 p-6 text-white md:block">
|
||||||
<h2 className="text-sm font-semibold">New Project</h2>
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.11)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.11)_1px,transparent_1px)] bg-[size:34px_34px]" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-cyan-500/20 to-transparent" />
|
||||||
|
<div className="relative flex h-full flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={docksideIcon}
|
||||||
|
alt=""
|
||||||
|
className="mb-5 size-16 rounded-2xl shadow-lg shadow-cyan-950/30"
|
||||||
|
/>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200">
|
||||||
|
Project launch
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold leading-tight">
|
||||||
|
Create a local site that feels ready to work.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Destination
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-semibold">
|
||||||
|
{directory ? directory.split('/').filter(Boolean).pop() : 'Choose a folder'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Project
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-semibold">
|
||||||
|
{projectName.trim() || 'Name pending'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-200/80 px-5 py-4 dark:border-white/10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">
|
||||||
|
{step === 'site' ? 'New Project' : `Set up ${getTypeLabel(projectType)}`}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
{(['site', 'setup'] as const).map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||||
|
step === item
|
||||||
|
? 'border-cyan-200 bg-cyan-50 text-cyan-800 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-cyan-200'
|
||||||
|
: 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-neutral-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="grid size-4 place-items-center rounded-full bg-current text-[10px]">
|
||||||
|
<span className="text-white dark:text-neutral-950">{index + 1}</span>
|
||||||
|
</span>
|
||||||
|
{item === 'site' ? 'Project' : 'Setup'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
className="rounded-lg p-1.5 text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-white/10 dark:hover:text-neutral-200"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto p-4">
|
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto p-5">
|
||||||
|
{step === 'site' ? (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
<label className={labelClass}>Project folder</label>
|
||||||
Project folder
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePickDirectory}
|
onClick={handlePickDirectory}
|
||||||
className="flex w-full items-center gap-2 rounded-md border border-dashed border-neutral-300 px-3 py-2 text-left text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
className={clsx(
|
||||||
|
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
|
||||||
|
directory
|
||||||
|
? 'border-cyan-200 bg-cyan-50/60 text-neutral-900 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-neutral-100'
|
||||||
|
: 'border-neutral-300 bg-neutral-50/70 text-neutral-500 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<FolderOpen size={16} className="flex-shrink-0 text-neutral-400" />
|
<span className="grid size-9 flex-shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-sm dark:bg-neutral-950/70 dark:text-cyan-300">
|
||||||
<span className="truncate">{directory ?? 'Choose a folder…'}</span>
|
<FolderOpen size={17} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">
|
||||||
|
{directory ?? 'Choose a folder…'}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
This becomes the project root.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{directory && <Check size={16} className="text-cyan-700 dark:text-cyan-300" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
<label className={labelClass}>Project name</label>
|
||||||
Project name
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={projectName}
|
value={projectName}
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
onChange={(e) => setProjectName(e.target.value)}
|
||||||
placeholder="my-project"
|
placeholder="my-project"
|
||||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
<label className={labelClass}>Project type</label>
|
||||||
Project type
|
<div className="grid max-h-64 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||||
</label>
|
{PROJECT_TYPES.map((t) => {
|
||||||
<select
|
const Icon = TYPE_ICONS[t.value] ?? Boxes
|
||||||
value={projectType}
|
const isSelected = projectType === t.value
|
||||||
onChange={(e) => setProjectType(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
return (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setProjectType(t.value)}
|
||||||
|
className={clsx(
|
||||||
|
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
|
||||||
|
isSelected
|
||||||
|
? 'border-cyan-300 bg-cyan-50 text-cyan-950 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
|
||||||
|
: 'border-neutral-200 bg-white/70 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{PROJECT_TYPES.map((t) => (
|
<span
|
||||||
<option key={t.value} value={t.value}>
|
className={clsx(
|
||||||
{t.label}
|
'grid size-9 flex-shrink-0 place-items-center rounded-lg',
|
||||||
</option>
|
isSelected
|
||||||
))}
|
? 'bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950'
|
||||||
</select>
|
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-950/70 dark:text-neutral-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-semibold">{t.label}</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t.value ? 'Use DDEV type preset' : 'Let DDEV inspect it'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
<label className={labelClass}>Docroot (optional)</label>
|
||||||
Docroot (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={docroot}
|
value={docroot}
|
||||||
onChange={(e) => setDocroot(e.target.value)}
|
onChange={(e) => setDocroot(e.target.value)}
|
||||||
placeholder="e.g. web, public — leave blank for project root"
|
placeholder="e.g. web, public — leave blank for project root"
|
||||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
{isWordpress ? (
|
) : projectType === 'wordpress' ? (
|
||||||
<div className="flex flex-col gap-3 rounded-md border border-neutral-200 p-3 dark:border-neutral-800">
|
<WordpressSetup
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
ref={setupRef}
|
||||||
WordPress core will be downloaded and installed automatically — the project is
|
projectName={projectName.trim()}
|
||||||
started as part of this.
|
onValidityChange={setSetupValid}
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
|
||||||
Site title
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={siteTitle}
|
|
||||||
onChange={(e) => setSiteTitle(e.target.value)}
|
|
||||||
placeholder={projectName || 'My WordPress Site'}
|
|
||||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
|
||||||
Admin username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={adminUser}
|
|
||||||
onChange={(e) => setAdminUser(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
|
||||||
Admin password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={adminPassword}
|
|
||||||
onChange={(e) => setAdminPassword(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
|
||||||
Admin email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={adminEmail}
|
|
||||||
onChange={(e) => setAdminEmail(e.target.value)}
|
|
||||||
placeholder="[email protected]"
|
|
||||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<GenericSetup
|
||||||
<input
|
ref={setupRef}
|
||||||
type="checkbox"
|
projectName={projectName.trim()}
|
||||||
checked={startAfterCreate}
|
onValidityChange={setSetupValid}
|
||||||
onChange={(e) => setStartAfterCreate(e.target.checked)}
|
|
||||||
/>
|
/>
|
||||||
Start project after creating
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 border-t border-neutral-200 p-3 dark:border-neutral-800">
|
<div className="flex items-center justify-between gap-3 border-t border-neutral-200/80 bg-neutral-50/80 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={step === 'site' ? onClose : () => setStep('site')}
|
||||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-white hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||||
>
|
>
|
||||||
Cancel
|
{step === 'site' ? null : <ArrowLeft size={14} />}
|
||||||
|
{step === 'site' ? 'Cancel' : 'Go back'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{step === 'site' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canContinue}
|
||||||
|
onClick={() => setStep('setup')}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-neutral-100 dark:text-neutral-900"
|
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Create
|
{isSubmitting ? 'Creating…' : 'Add Site'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||||
|
import { PlayCircle } from 'lucide-react'
|
||||||
|
import { useStartProject } from '../../../hooks/useDdev'
|
||||||
|
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||||
|
|
||||||
|
// Fallback setup panel for any project type without a dedicated one yet
|
||||||
|
// (see registry.ts). Just scaffolds via `ddev config` and optionally starts
|
||||||
|
// the project — no type-specific installer.
|
||||||
|
export const GenericSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function GenericSetup(
|
||||||
|
{ onValidityChange },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const [startAfterCreate, setStartAfterCreate] = useState(true)
|
||||||
|
const startProject = useStartProject()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidityChange(true)
|
||||||
|
}, [onValidityChange])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
runPostCreate: async ({ projectName }: TypeSetupContext) => {
|
||||||
|
if (startAfterCreate) {
|
||||||
|
await startProject.mutateAsync(projectName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||||
|
<PlayCircle size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
|
||||||
|
Ready after DDEV config
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||||
|
This type uses DDEV defaults, then you can finish the app-specific install in the
|
||||||
|
project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold">Start after creating</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
Launch the environment as soon as config is written.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={startAfterCreate}
|
||||||
|
onChange={(e) => setStartAfterCreate(e.target.checked)}
|
||||||
|
className="size-4 accent-cyan-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp, Globe2, KeyRound, Mail, Type, UserRound } from 'lucide-react'
|
||||||
|
import { useDownloadWordpress, useSetupWordpress } from '../../../hooks/useCreateProject'
|
||||||
|
import { useStartProject } from '../../../hooks/useDdev'
|
||||||
|
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ value: 'en_US', label: 'English (United States)' },
|
||||||
|
{ value: 'en_GB', label: 'English (UK)' },
|
||||||
|
{ value: 'de_DE', label: 'German' },
|
||||||
|
{ value: 'es_ES', label: 'Spanish (Spain)' },
|
||||||
|
{ value: 'fr_FR', label: 'French (France)' },
|
||||||
|
{ value: 'it_IT', label: 'Italian' },
|
||||||
|
{ value: 'pt_BR', label: 'Portuguese (Brazil)' },
|
||||||
|
{ value: 'nl_NL', label: 'Dutch' },
|
||||||
|
{ value: 'ja', label: 'Japanese' }
|
||||||
|
]
|
||||||
|
|
||||||
|
type Multisite = 'none' | 'subdirectory' | 'subdomain'
|
||||||
|
|
||||||
|
const MULTISITE_OPTIONS: { value: Multisite; label: string }[] = [
|
||||||
|
{ value: 'none', label: 'No' },
|
||||||
|
{ value: 'subdirectory', label: 'Yes – Subdirectory' },
|
||||||
|
{ value: 'subdomain', label: 'Yes – Subdomain' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||||
|
const labelClass =
|
||||||
|
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||||
|
|
||||||
|
export const WordpressSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function WordpressSetup(
|
||||||
|
{ projectName, onValidityChange },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const [siteTitle, setSiteTitle] = useState('')
|
||||||
|
const [adminUser, setAdminUser] = useState('admin')
|
||||||
|
const [adminPassword, setAdminPassword] = useState('')
|
||||||
|
const [adminEmail, setAdminEmail] = useState('')
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
const [language, setLanguage] = useState('en_US')
|
||||||
|
const [multisite, setMultisite] = useState<Multisite>('none')
|
||||||
|
|
||||||
|
const startProject = useStartProject()
|
||||||
|
const downloadWordpress = useDownloadWordpress()
|
||||||
|
const setupWordpress = useSetupWordpress()
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
adminUser.trim().length > 0 && adminPassword.trim().length > 0 && adminEmail.trim().length > 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidityChange(isValid)
|
||||||
|
}, [isValid, onValidityChange])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
runPostCreate: async ({ directory, projectName: name }: TypeSetupContext) => {
|
||||||
|
// wp-cli needs the containers running, so this ignores any "start
|
||||||
|
// after creating" preference — an unstarted WordPress project would
|
||||||
|
// just be the same half-built state this whole flow exists to avoid.
|
||||||
|
await startProject.mutateAsync(name)
|
||||||
|
await downloadWordpress.mutateAsync({ directory, locale: language })
|
||||||
|
await setupWordpress.mutateAsync({
|
||||||
|
directory,
|
||||||
|
siteUrl: `https://${name}.ddev.site`,
|
||||||
|
title: siteTitle.trim() || name,
|
||||||
|
adminUser: adminUser.trim(),
|
||||||
|
adminPassword: adminPassword.trim(),
|
||||||
|
adminEmail: adminEmail.trim(),
|
||||||
|
multisite
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||||
|
<Globe2 size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
|
||||||
|
WordPress install
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||||
|
Core downloads automatically and the project starts for setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className={labelClass}>Site title</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Type
|
||||||
|
size={15}
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={siteTitle}
|
||||||
|
onChange={(e) => setSiteTitle(e.target.value)}
|
||||||
|
placeholder={projectName || 'My WordPress Site'}
|
||||||
|
className={`${inputClass} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Admin username</label>
|
||||||
|
<div className="relative">
|
||||||
|
<UserRound
|
||||||
|
size={15}
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={adminUser}
|
||||||
|
onChange={(e) => setAdminUser(e.target.value)}
|
||||||
|
className={`${inputClass} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Admin password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<KeyRound
|
||||||
|
size={15}
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
|
className={`${inputClass} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className={labelClass}>Admin email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail
|
||||||
|
size={15}
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={adminEmail}
|
||||||
|
onChange={(e) => setAdminEmail(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
className={`${inputClass} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white/70 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 transition hover:border-cyan-200 hover:bg-cyan-50/50 hover:text-cyan-800 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"
|
||||||
|
>
|
||||||
|
<span>Advanced options</span>
|
||||||
|
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="grid gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Select language</label>
|
||||||
|
<select
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{LANGUAGES.map((l) => (
|
||||||
|
<option key={l.value} value={l.value}>
|
||||||
|
{l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Is this a WordPress Multisite?</label>
|
||||||
|
<select
|
||||||
|
value={multisite}
|
||||||
|
onChange={(e) => setMultisite(e.target.value as Multisite)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{MULTISITE_OPTIONS.map((m) => (
|
||||||
|
<option key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const PROJECT_TYPES = [
|
||||||
|
{ value: '', label: 'Auto-detect' },
|
||||||
|
{ value: 'php', label: 'PHP (generic)' },
|
||||||
|
{ value: 'wordpress', label: 'WordPress' },
|
||||||
|
{ value: 'drupal', label: 'Drupal' },
|
||||||
|
{ value: 'laravel', label: 'Laravel' },
|
||||||
|
{ value: 'backdrop', label: 'Backdrop' },
|
||||||
|
{ value: 'craftcms', label: 'Craft CMS' },
|
||||||
|
{ value: 'magento2', label: 'Magento 2' },
|
||||||
|
{ value: 'shopware6', label: 'Shopware 6' },
|
||||||
|
{ value: 'symfony', label: 'Symfony' },
|
||||||
|
{ value: 'typo3', label: 'TYPO3' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getTypeLabel(projectType: string): string {
|
||||||
|
const found = PROJECT_TYPES.find((t) => t.value === projectType)
|
||||||
|
return found?.value ? found.label : 'project'
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
|
||||||
|
|
||||||
|
export interface TypeSetupContext {
|
||||||
|
directory: string
|
||||||
|
projectName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each project type's setup panel exposes this so the wizard can trigger
|
||||||
|
// whatever post-`ddev config` work that type needs (downloading app core,
|
||||||
|
// running an installer, seeding a database, ...) without knowing the details.
|
||||||
|
export interface TypeSetupHandle {
|
||||||
|
runPostCreate: (ctx: TypeSetupContext) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeSetupProps {
|
||||||
|
projectName: string
|
||||||
|
onValidityChange: (valid: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypeSetupComponent = ForwardRefExoticComponent<
|
||||||
|
TypeSetupProps & RefAttributes<TypeSetupHandle>
|
||||||
|
>
|
||||||
@@ -9,7 +9,7 @@ export function StatusBar(): React.JSX.Element {
|
|||||||
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
|
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-neutral-200 bg-neutral-50 px-3 text-xs text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
|
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-white/70 bg-white/75 px-3 text-xs text-neutral-500 backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/75 dark:text-neutral-400">
|
||||||
{operationId && label ? (
|
{operationId && label ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -18,7 +18,7 @@ export function StatusBar(): React.JSX.Element {
|
|||||||
setActiveOperation(operationId)
|
setActiveOperation(operationId)
|
||||||
setPanelOpen(true)
|
setPanelOpen(true)
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 hover:text-neutral-900 dark:hover:text-neutral-100"
|
className="flex items-center gap-1.5 transition hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||||
>
|
>
|
||||||
<Loader2 size={12} className="animate-spin" />
|
<Loader2 size={12} className="animate-spin" />
|
||||||
{label}…
|
{label}…
|
||||||
@@ -26,7 +26,7 @@ export function StatusBar(): React.JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.api.terminal.cancel(operationId)}
|
onClick={() => window.api.terminal.cancel(operationId)}
|
||||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -9,26 +9,32 @@ export function AddonsSection({ name }: { name: string }): React.JSX.Element {
|
|||||||
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">Add-ons</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
|
Add-ons
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsBrowserOpen(true)}
|
onClick={() => setIsBrowserOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900"
|
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<Puzzle size={12} /> Browse Add-ons
|
<Puzzle size={12} /> Browse Add-ons
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-neutral-500">Loading add-ons…</p>
|
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||||
|
Loading add-ons…
|
||||||
|
</p>
|
||||||
) : !installed || installed.length === 0 ? (
|
) : !installed || installed.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500">No add-ons installed.</p>
|
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||||
|
No add-ons installed.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
|
<div className="overflow-hidden rounded-lg border border-neutral-200/80 bg-white dark:border-white/10 dark:bg-neutral-950/70">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
|
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-white/[0.04] dark:text-neutral-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 font-medium">Name</th>
|
<th className="px-3 py-2 font-medium">Name</th>
|
||||||
<th className="px-3 py-2 font-medium">Version</th>
|
<th className="px-3 py-2 font-medium">Version</th>
|
||||||
@@ -40,7 +46,7 @@ export function AddonsSection({ name }: { name: string }): React.JSX.Element {
|
|||||||
{installed.map((addon) => (
|
{installed.map((addon) => (
|
||||||
<tr
|
<tr
|
||||||
key={addon.Name}
|
key={addon.Name}
|
||||||
className="border-t border-neutral-200 dark:border-neutral-800"
|
className="border-t border-neutral-200/80 transition hover:bg-cyan-50/40 dark:border-white/10 dark:hover:bg-cyan-400/5"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 font-medium">{addon.Name}</td>
|
<td className="px-3 py-2 font-medium">{addon.Name}</td>
|
||||||
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
||||||
@@ -60,7 +66,7 @@ export function AddonsSection({ name }: { name: string }): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Remove"
|
title="Remove"
|
||||||
className="rounded p-1 text-red-500 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-950"
|
className="rounded p-1 text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ export function DatabaseSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">Database</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
<div className="flex items-center gap-2">
|
Database
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{isNaming ? (
|
{isNaming ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -63,19 +65,19 @@ export function DatabaseSection({
|
|||||||
if (e.key === 'Enter') submitSnapshotName()
|
if (e.key === 'Enter') submitSnapshotName()
|
||||||
if (e.key === 'Escape') setIsNaming(false)
|
if (e.key === 'Escape') setIsNaming(false)
|
||||||
}}
|
}}
|
||||||
className="rounded-md border border-neutral-300 px-2 py-1 text-xs dark:border-neutral-700 dark:bg-neutral-900"
|
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs dark:border-white/10 dark:bg-neutral-950"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submitSnapshotName}
|
onClick={submitSnapshotName}
|
||||||
className="rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-700 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300"
|
className="rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white transition hover:bg-cyan-500"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsNaming(false)}
|
onClick={() => setIsNaming(false)}
|
||||||
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 transition hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -86,7 +88,7 @@ export function DatabaseSection({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => importDatabase.mutate()}
|
onClick={() => importDatabase.mutate()}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-900"
|
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<Upload size={12} /> Import
|
<Upload size={12} /> Import
|
||||||
</button>
|
</button>
|
||||||
@@ -94,7 +96,7 @@ export function DatabaseSection({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => exportDatabase.mutate()}
|
onClick={() => exportDatabase.mutate()}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-900"
|
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<Download size={12} /> Export
|
<Download size={12} /> Export
|
||||||
</button>
|
</button>
|
||||||
@@ -102,7 +104,7 @@ export function DatabaseSection({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => setIsNaming(true)}
|
onClick={() => setIsNaming(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300"
|
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white shadow-sm shadow-cyan-900/[0.15] transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Camera size={12} /> Snapshot
|
<Camera size={12} /> Snapshot
|
||||||
</button>
|
</button>
|
||||||
@@ -112,13 +114,17 @@ export function DatabaseSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-neutral-500">Loading snapshots…</p>
|
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||||
|
Loading snapshots…
|
||||||
|
</p>
|
||||||
) : !snapshots || snapshots.length === 0 ? (
|
) : !snapshots || snapshots.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500">No snapshots yet.</p>
|
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||||
|
No snapshots yet.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
|
<div className="overflow-hidden rounded-lg border border-neutral-200/80 bg-white dark:border-white/10 dark:bg-neutral-950/70">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
|
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-white/[0.04] dark:text-neutral-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 font-medium">Name</th>
|
<th className="px-3 py-2 font-medium">Name</th>
|
||||||
<th className="px-3 py-2 font-medium">Created</th>
|
<th className="px-3 py-2 font-medium">Created</th>
|
||||||
@@ -129,7 +135,7 @@ export function DatabaseSection({
|
|||||||
{snapshots.map((snapshot) => (
|
{snapshots.map((snapshot) => (
|
||||||
<tr
|
<tr
|
||||||
key={snapshot.Name}
|
key={snapshot.Name}
|
||||||
className="border-t border-neutral-200 dark:border-neutral-800"
|
className="border-t border-neutral-200/80 transition hover:bg-cyan-50/40 dark:border-white/10 dark:hover:bg-cyan-400/5"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 font-medium">{snapshot.Name}</td>
|
<td className="px-3 py-2 font-medium">{snapshot.Name}</td>
|
||||||
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
||||||
@@ -142,7 +148,7 @@ export function DatabaseSection({
|
|||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => restoreSnapshot.mutate(snapshot.Name)}
|
onClick={() => restoreSnapshot.mutate(snapshot.Name)}
|
||||||
title="Restore"
|
title="Restore"
|
||||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-neutral-800"
|
className="rounded p-1 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -155,7 +161,7 @@ export function DatabaseSection({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
className="rounded p-1 text-red-500 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-950"
|
className="rounded p-1 text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-400/10"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FileText, KeyRound, Play, RotateCw, Square, Trash2 } from 'lucide-react'
|
import { clsx } from 'clsx'
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
Code2,
|
||||||
|
Database,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
Gauge,
|
||||||
|
KeyRound,
|
||||||
|
Play,
|
||||||
|
RotateCw,
|
||||||
|
Server,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
Zap
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { EnvironmentUpdate } from '@shared/types'
|
||||||
import {
|
import {
|
||||||
useDeleteProject,
|
useDeleteProject,
|
||||||
useProjectDetail,
|
useProjectDetail,
|
||||||
useRestartProject,
|
useRestartProject,
|
||||||
useStartProject,
|
useStartProject,
|
||||||
useStopProject
|
useStopProject,
|
||||||
|
useUpdateEnvironment
|
||||||
} from '../../hooks/useDdev'
|
} from '../../hooks/useDdev'
|
||||||
import { StatusBadge } from './StatusBadge'
|
import { StatusBadge } from './StatusBadge'
|
||||||
import { DatabaseSection } from './DatabaseSection'
|
import { DatabaseSection } from './DatabaseSection'
|
||||||
@@ -13,12 +30,49 @@ import { AddonsSection } from './AddonsSection'
|
|||||||
import { LogViewer } from '../logs/LogViewer'
|
import { LogViewer } from '../logs/LogViewer'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
|
const PHP_VERSIONS = [
|
||||||
|
'5.6',
|
||||||
|
'7.0',
|
||||||
|
'7.1',
|
||||||
|
'7.2',
|
||||||
|
'7.3',
|
||||||
|
'7.4',
|
||||||
|
'8.0',
|
||||||
|
'8.1',
|
||||||
|
'8.2',
|
||||||
|
'8.3',
|
||||||
|
'8.4',
|
||||||
|
'8.5'
|
||||||
|
]
|
||||||
|
|
||||||
|
const WEBSERVER_TYPES = [
|
||||||
|
{ value: 'nginx-fpm', label: 'nginx' },
|
||||||
|
{ value: 'apache-fpm', label: 'Apache' },
|
||||||
|
{ value: 'generic', label: 'Generic' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const DATABASE_OPTIONS = [
|
||||||
|
{ value: 'mariadb:11.8', label: 'MariaDB 11.8' },
|
||||||
|
{ value: 'mariadb:10.11', label: 'MariaDB 10.11' },
|
||||||
|
{ value: 'mariadb:10.6', label: 'MariaDB 10.6' },
|
||||||
|
{ value: 'mysql:8.4', label: 'MySQL 8.4' },
|
||||||
|
{ value: 'mysql:8.0', label: 'MySQL 8.0' },
|
||||||
|
{ value: 'mysql:5.7', label: 'MySQL 5.7' },
|
||||||
|
{ value: 'postgres:17', label: 'PostgreSQL 17' },
|
||||||
|
{ value: 'postgres:16', label: 'PostgreSQL 16' },
|
||||||
|
{ value: 'postgres:15', label: 'PostgreSQL 15' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const heroFieldClass =
|
||||||
|
'w-full rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-sm font-semibold text-white transition hover:border-cyan-300/40 hover:bg-white/10 focus:border-cyan-300/60 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
|
||||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
||||||
const startProject = useStartProject()
|
const startProject = useStartProject()
|
||||||
const stopProject = useStopProject()
|
const stopProject = useStopProject()
|
||||||
const restartProject = useRestartProject()
|
const restartProject = useRestartProject()
|
||||||
const deleteProject = useDeleteProject()
|
const deleteProject = useDeleteProject()
|
||||||
|
const updateEnvironment = useUpdateEnvironment()
|
||||||
const selectProject = useAppStore((s) => s.selectProject)
|
const selectProject = useAppStore((s) => s.selectProject)
|
||||||
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
||||||
|
|
||||||
@@ -28,6 +82,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
restartProject.isPending ||
|
restartProject.isPending ||
|
||||||
deleteProject.isPending
|
deleteProject.isPending
|
||||||
|
|
||||||
|
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
|
||||||
|
|
||||||
function handleDelete(): void {
|
function handleDelete(): void {
|
||||||
if (!project) return
|
if (!project) return
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
@@ -40,8 +96,26 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> {
|
||||||
|
if (!project) return
|
||||||
|
await updateEnvironment.mutateAsync({ name: project.name, approot: project.approot, updates })
|
||||||
|
if (project.status === 'running') {
|
||||||
|
await restartProject.mutateAsync(project.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDatabaseChange(value: string): void {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Changing the database type restarts the project and may require DDEV to migrate or ' +
|
||||||
|
'recreate the database. Consider taking a snapshot first if this project has data you ' +
|
||||||
|
'want to keep. Continue?'
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
void applyEnvironmentChange({ database: value })
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="p-6 text-sm text-neutral-500">Loading {name}…</div>
|
return <div className="p-6 text-sm text-neutral-500 dark:text-neutral-400">Loading {name}…</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !project) {
|
if (isError || !project) {
|
||||||
@@ -53,23 +127,49 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRunning = project.status === 'running'
|
const isRunning = project.status === 'running'
|
||||||
|
const services = Object.values(project.services)
|
||||||
|
const runningServices = services.filter((service) => service.status === 'running').length
|
||||||
|
|
||||||
|
const phpVersions =
|
||||||
|
project.php_version && !PHP_VERSIONS.includes(project.php_version)
|
||||||
|
? [project.php_version, ...PHP_VERSIONS]
|
||||||
|
: PHP_VERSIONS
|
||||||
|
|
||||||
|
const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}`
|
||||||
|
const databaseOptions = DATABASE_OPTIONS.some((o) => o.value === currentDatabase)
|
||||||
|
? DATABASE_OPTIONS
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
value: currentDatabase,
|
||||||
|
label: `${project.dbinfo.database_type} ${project.dbinfo.database_version}`
|
||||||
|
},
|
||||||
|
...DATABASE_OPTIONS
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-6">
|
||||||
<header className="flex items-center justify-between">
|
<header className="overflow-hidden rounded-2xl border border-white/70 bg-neutral-950 text-white shadow-[0_24px_70px_rgba(15,23,42,0.18)] dark:border-white/10">
|
||||||
<div>
|
<div className="relative p-5">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.09)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.09)_1px,transparent_1px)] bg-[size:36px_36px]" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-cyan-500/10 to-transparent" />
|
||||||
|
<div className="relative flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-medium text-cyan-100">
|
||||||
|
<Gauge size={13} />
|
||||||
|
{runningServices} of {services.length} services running
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">{project.name}</h2>
|
<h2 className="truncate text-3xl font-semibold">{project.name}</h2>
|
||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{project.approot}</p>
|
<p className="mt-2 max-w-3xl truncate text-sm text-neutral-300">{project.approot}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isRunning || isBusy}
|
disabled={isRunning || isBusy}
|
||||||
onClick={() => startProject.mutate(project.name)}
|
onClick={() => startProject.mutate(project.name)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-3 py-1.5 text-sm font-semibold text-white shadow-sm shadow-emerald-950/20 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Play size={14} /> Start
|
<Play size={14} /> Start
|
||||||
</button>
|
</button>
|
||||||
@@ -77,7 +177,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!isRunning || isBusy}
|
disabled={!isRunning || isBusy}
|
||||||
onClick={() => stopProject.mutate(project.name)}
|
onClick={() => stopProject.mutate(project.name)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Square size={14} /> Stop
|
<Square size={14} /> Stop
|
||||||
</button>
|
</button>
|
||||||
@@ -85,7 +185,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!isRunning || isBusy}
|
disabled={!isRunning || isBusy}
|
||||||
onClick={() => restartProject.mutate(project.name)}
|
onClick={() => restartProject.mutate(project.name)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-400 px-3 py-1.5 text-sm font-semibold text-neutral-950 shadow-sm shadow-cyan-950/20 transition hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<RotateCw size={14} /> Restart
|
<RotateCw size={14} /> Restart
|
||||||
</button>
|
</button>
|
||||||
@@ -93,7 +193,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!isRunning}
|
disabled={!isRunning}
|
||||||
onClick={() => setIsLogsOpen(true)}
|
onClick={() => setIsLogsOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-900"
|
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<FileText size={14} /> Logs
|
<FileText size={14} /> Logs
|
||||||
</button>
|
</button>
|
||||||
@@ -102,7 +202,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
href={`${project.primary_url}/wp-admin/`}
|
href={`${project.primary_url}/wp-admin/`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900"
|
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]"
|
||||||
>
|
>
|
||||||
<KeyRound size={14} /> WP Admin
|
<KeyRound size={14} /> WP Admin
|
||||||
</a>
|
</a>
|
||||||
@@ -111,87 +211,182 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950"
|
className="inline-flex items-center gap-1.5 rounded-md border border-red-300/20 bg-red-400/10 px-3 py-1.5 text-sm font-medium text-red-100 transition hover:bg-red-400/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} /> Delete
|
<Trash2 size={14} /> Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||||
|
<Boxes size={17} className="mb-3 text-cyan-200" />
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Project type
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-semibold text-white">{project.type}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||||
|
<Code2 size={17} className="mb-3 text-cyan-200" />
|
||||||
|
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
PHP
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={project.php_version ?? ''}
|
||||||
|
disabled={isEnvUpdating}
|
||||||
|
onChange={(e) => void applyEnvironmentChange({ phpVersion: e.target.value })}
|
||||||
|
className={heroFieldClass}
|
||||||
|
>
|
||||||
|
{phpVersions.map((v) => (
|
||||||
|
<option key={v} value={v} className="text-neutral-900">
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||||
|
<Server size={17} className="mb-3 text-cyan-200" />
|
||||||
|
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Web server
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={project.webserver_type ?? 'nginx-fpm'}
|
||||||
|
disabled={isEnvUpdating}
|
||||||
|
onChange={(e) => void applyEnvironmentChange({ webserverType: e.target.value })}
|
||||||
|
className={heroFieldClass}
|
||||||
|
>
|
||||||
|
{WEBSERVER_TYPES.map((w) => (
|
||||||
|
<option key={w.value} value={w.value} className="text-neutral-900">
|
||||||
|
{w.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<Zap size={17} className="text-cyan-200" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={project.xdebug_enabled}
|
||||||
|
aria-label="Toggle Xdebug"
|
||||||
|
disabled={isEnvUpdating}
|
||||||
|
onClick={() =>
|
||||||
|
void applyEnvironmentChange({ xdebugEnabled: !project.xdebug_enabled })
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
'relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
project.xdebug_enabled ? 'bg-cyan-400' : 'bg-white/15'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'inline-block size-3.5 transform rounded-full bg-white shadow transition',
|
||||||
|
project.xdebug_enabled ? 'translate-x-4' : 'translate-x-1'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">Xdebug</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-semibold text-white">
|
||||||
|
{project.xdebug_enabled ? 'Enabled' : 'Off'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section>
|
<div className="grid gap-5 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">URLs</h3>
|
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||||
<ul className="flex flex-col gap-1">
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
|
URLs
|
||||||
|
</h3>
|
||||||
|
<ul className="grid gap-2">
|
||||||
{project.urls.map((url) => (
|
{project.urls.map((url) => (
|
||||||
<li key={url}>
|
<li key={url}>
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
className="group flex items-center justify-between gap-3 rounded-lg border border-neutral-200/70 bg-neutral-50/70 px-3 py-2 text-sm text-cyan-700 transition hover:border-cyan-200 hover:bg-cyan-50 dark:border-white/10 dark:bg-white/[0.04] dark:text-cyan-300 dark:hover:border-cyan-400/30 dark:hover:bg-cyan-400/10"
|
||||||
>
|
>
|
||||||
{url}
|
<span className="truncate">{url}</span>
|
||||||
|
<ExternalLink
|
||||||
|
size={14}
|
||||||
|
className="flex-shrink-0 text-neutral-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
|
<Database size={14} className="text-cyan-600 dark:text-cyan-300" />
|
||||||
|
Database credentials
|
||||||
|
</h3>
|
||||||
|
<dl className="grid grid-cols-[minmax(110px,0.45fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
<select
|
||||||
|
value={currentDatabase}
|
||||||
|
disabled={isEnvUpdating}
|
||||||
|
onChange={(e) => handleDatabaseChange(e.target.value)}
|
||||||
|
className="w-full max-w-[220px] rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm transition hover:border-cyan-300 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
{databaseOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
|
||||||
|
<dd className="font-medium">{project.dbinfo.dbname}</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
|
||||||
|
<dd className="font-medium">{project.dbinfo.username}</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
|
||||||
|
<dd className="font-mono text-xs">{project.dbinfo.password}</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
|
||||||
|
<dd className="font-medium">{project.dbinfo.published_port}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||||
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
Services
|
Services
|
||||||
</h3>
|
</h3>
|
||||||
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<table className="w-full text-sm">
|
{services.map((service) => (
|
||||||
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
|
<div
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 font-medium">Service</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Status</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Image</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{Object.values(project.services).map((service) => (
|
|
||||||
<tr
|
|
||||||
key={service.short_name}
|
key={service.short_name}
|
||||||
className="border-t border-neutral-200 dark:border-neutral-800"
|
className="rounded-xl border border-neutral-200/80 bg-white/80 p-4 transition hover:border-cyan-200 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/30"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 font-medium">{service.short_name}</td>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<td className="px-3 py-2">
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold">{service.short_name}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{service.full_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<StatusBadge status={service.status} />
|
<StatusBadge status={service.status} />
|
||||||
</td>
|
</div>
|
||||||
<td className="truncate px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
<p className="mt-4 truncate rounded-lg bg-neutral-100 px-3 py-2 font-mono text-xs text-neutral-600 dark:bg-neutral-950/70 dark:text-neutral-300">
|
||||||
{service.image}
|
{service.image}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
|
||||||
Database credentials
|
|
||||||
</h3>
|
|
||||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
|
||||||
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
|
|
||||||
<dd>
|
|
||||||
{project.dbinfo.database_type} {project.dbinfo.database_version}
|
|
||||||
</dd>
|
|
||||||
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
|
|
||||||
<dd>{project.dbinfo.dbname}</dd>
|
|
||||||
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
|
|
||||||
<dd>{project.dbinfo.username}</dd>
|
|
||||||
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
|
|
||||||
<dd>{project.dbinfo.password}</dd>
|
|
||||||
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
|
|
||||||
<dd>{project.dbinfo.published_port}</dd>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DatabaseSection name={project.name} approot={project.approot} />
|
<DatabaseSection name={project.name} approot={project.approot} />
|
||||||
|
|
||||||
<AddonsSection name={project.name} />
|
<AddonsSection name={project.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLogsOpen && (
|
{isLogsOpen && (
|
||||||
<LogViewer
|
<LogViewer
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { FolderKanban, Loader2 } from 'lucide-react'
|
||||||
import { useProjects } from '../../hooks/useDdev'
|
import { useProjects } from '../../hooks/useDdev'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { StatusBadge } from './StatusBadge'
|
import { StatusBadge } from './StatusBadge'
|
||||||
@@ -9,7 +10,12 @@ export function ProjectList(): React.JSX.Element {
|
|||||||
const selectProject = useAppStore((s) => s.selectProject)
|
const selectProject = useAppStore((s) => s.selectProject)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="p-4 text-sm text-neutral-500">Loading projects…</div>
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
<Loader2 size={15} className="animate-spin text-cyan-600 dark:text-cyan-300" />
|
||||||
|
Loading projects…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
@@ -22,28 +28,46 @@ export function ProjectList(): React.JSX.Element {
|
|||||||
|
|
||||||
if (!projects || projects.length === 0) {
|
if (!projects || projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-neutral-500">
|
<div className="m-3 rounded-lg border border-dashed border-neutral-300 bg-white/60 p-4 text-sm text-neutral-500 dark:border-neutral-700 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||||
No DDEV projects found. Run <code>ddev start</code> in a project directory to see it here.
|
<div className="mb-3 grid size-10 place-items-center rounded-lg bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300">
|
||||||
|
<FolderKanban size={18} />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-neutral-800 dark:text-neutral-200">No DDEV projects found</p>
|
||||||
|
<p className="mt-1 leading-5">
|
||||||
|
Run{' '}
|
||||||
|
<code className="rounded bg-neutral-100 px-1 py-0.5 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200">
|
||||||
|
ddev start
|
||||||
|
</code>{' '}
|
||||||
|
in a project directory to see it here.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col gap-1 p-2">
|
<ul className="flex flex-col gap-1.5 p-2.5">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<li key={project.name}>
|
<li key={project.name}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectProject(project.name)}
|
onClick={() => selectProject(project.name)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex w-full flex-col gap-1 rounded-lg px-3 py-2 text-left transition-colors',
|
'group relative flex w-full flex-col gap-1 overflow-hidden rounded-lg border px-3 py-2.5 text-left transition',
|
||||||
selectedProjectName === project.name
|
selectedProjectName === project.name
|
||||||
? 'bg-blue-100 dark:bg-blue-900/40'
|
? 'border-cyan-200 bg-cyan-50/90 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/25 dark:bg-cyan-400/10'
|
||||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
: 'border-transparent hover:border-neutral-200 hover:bg-white/70 hover:shadow-sm dark:hover:border-white/10 dark:hover:bg-white/[0.04]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute inset-y-2 left-0 w-1 rounded-r-full transition-opacity',
|
||||||
|
selectedProjectName === project.name
|
||||||
|
? 'bg-cyan-500 opacity-100'
|
||||||
|
: 'bg-neutral-300 opacity-0 group-hover:opacity-100 dark:bg-neutral-600'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-sm font-medium">{project.name}</span>
|
<span className="truncate text-sm font-semibold">{project.name}</span>
|
||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.status} />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
running: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400',
|
running:
|
||||||
stopped: 'bg-neutral-200 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400',
|
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300',
|
||||||
paused: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400'
|
stopped:
|
||||||
|
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400',
|
||||||
|
paused:
|
||||||
|
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-300'
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STYLE = 'bg-neutral-200 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'
|
const DEFAULT_STYLE =
|
||||||
|
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400'
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: string }): React.JSX.Element {
|
export function StatusBadge({ status }: { status: string }): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize',
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
|
||||||
STATUS_STYLES[status] ?? DEFAULT_STYLE
|
STATUS_STYLES[status] ?? DEFAULT_STYLE
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'size-1.5 rounded-full',
|
||||||
|
status === 'running'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: status === 'paused'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ export function TerminalPanel(): React.JSX.Element | null {
|
|||||||
if (!isPanelOpen || !activeOperationId || !operation) return null
|
if (!isPanelOpen || !activeOperationId || !operation) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 flex-shrink-0 flex-col border-t border-neutral-200 bg-neutral-950 dark:border-neutral-800">
|
<div className="flex h-64 flex-shrink-0 flex-col border-t border-cyan-500/20 bg-neutral-950 shadow-[0_-16px_40px_rgba(15,23,42,0.2)] dark:border-cyan-400/20">
|
||||||
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-1.5">
|
<div className="flex items-center justify-between border-b border-white/10 bg-white/[0.03] px-3 py-1.5">
|
||||||
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
|
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPanelOpen(false)}
|
onClick={() => setPanelOpen(false)}
|
||||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
|
className="rounded p-1 text-neutral-400 transition hover:bg-white/10 hover:text-neutral-200"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface WordpressSetupInput {
|
|||||||
adminUser: string
|
adminUser: string
|
||||||
adminPassword: string
|
adminPassword: string
|
||||||
adminEmail: string
|
adminEmail: string
|
||||||
|
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginOperation(label: string): string {
|
function beginOperation(label: string): string {
|
||||||
@@ -44,18 +45,30 @@ export function useCreateProject(): UseMutationResult<void, Error, CreateProject
|
|||||||
// bridge, not WordPress core itself — see create.ts in the main process for
|
// bridge, not WordPress core itself — see create.ts in the main process for
|
||||||
// why this and useSetupWordpress are separate tracked operations run after
|
// why this and useSetupWordpress are separate tracked operations run after
|
||||||
// the project has been started.
|
// the project has been started.
|
||||||
export function useDownloadWordpress(): UseMutationResult<void, Error, { directory: string }> {
|
export function useDownloadWordpress(): UseMutationResult<
|
||||||
|
void,
|
||||||
|
Error,
|
||||||
|
{ directory: string; locale: string }
|
||||||
|
> {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ directory }) => {
|
mutationFn: async ({ directory, locale }) => {
|
||||||
const operationId = beginOperation('Download WordPress core')
|
const operationId = beginOperation('Download WordPress core')
|
||||||
await window.api.create.downloadWordpress(operationId, directory)
|
await window.api.create.downloadWordpress(operationId, directory, locale)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSetupInput> {
|
export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSetupInput> {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ directory, siteUrl, title, adminUser, adminPassword, adminEmail }) => {
|
mutationFn: async ({
|
||||||
|
directory,
|
||||||
|
siteUrl,
|
||||||
|
title,
|
||||||
|
adminUser,
|
||||||
|
adminPassword,
|
||||||
|
adminEmail,
|
||||||
|
multisite
|
||||||
|
}) => {
|
||||||
const operationId = beginOperation('Install WordPress')
|
const operationId = beginOperation('Install WordPress')
|
||||||
await window.api.create.setupWordpress(
|
await window.api.create.setupWordpress(
|
||||||
operationId,
|
operationId,
|
||||||
@@ -64,7 +77,8 @@ export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSet
|
|||||||
title,
|
title,
|
||||||
adminUser,
|
adminUser,
|
||||||
adminPassword,
|
adminPassword,
|
||||||
adminEmail
|
adminEmail,
|
||||||
|
multisite
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type UseMutationResult,
|
type UseMutationResult,
|
||||||
type UseQueryResult
|
type UseQueryResult
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import type { DdevProjectDetail, DdevProjectSummary } from '@shared/types'
|
import type { DdevProjectDetail, DdevProjectSummary, EnvironmentUpdate } from '@shared/types'
|
||||||
import { useTerminalStore } from '../stores/terminalStore'
|
import { useTerminalStore } from '../stores/terminalStore'
|
||||||
import { useStatusStore } from '../stores/statusStore'
|
import { useStatusStore } from '../stores/statusStore'
|
||||||
|
|
||||||
@@ -75,3 +75,26 @@ export function useDeleteProject(): UseMutationResult<void, Error, string> {
|
|||||||
window.api.projects.delete(operationId, name)
|
window.api.projects.delete(operationId, name)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only runs `ddev config` — the caller is expected to follow a successful
|
||||||
|
// call with useRestartProject() if the project is currently running, same
|
||||||
|
// split as useCreateProject's configure/start pair.
|
||||||
|
export function useUpdateEnvironment(): UseMutationResult<
|
||||||
|
void,
|
||||||
|
Error,
|
||||||
|
{ name: string; approot: string; updates: EnvironmentUpdate }
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ name, approot, updates }) => {
|
||||||
|
const operationId = crypto.randomUUID()
|
||||||
|
useTerminalStore.getState().startOperation(operationId, `Update ${name} environment`)
|
||||||
|
useStatusStore.getState().begin(operationId, `Update ${name} environment`)
|
||||||
|
await window.api.projects.updateEnvironment(operationId, name, approot, updates)
|
||||||
|
},
|
||||||
|
onSettled: (_data, _error, { name }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ export interface DdevProjectDetail extends DdevProjectSummary {
|
|||||||
xdebug_enabled: boolean
|
xdebug_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentUpdate {
|
||||||
|
phpVersion?: string
|
||||||
|
webserverType?: string
|
||||||
|
database?: string
|
||||||
|
xdebugEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface DdevSnapshot {
|
export interface DdevSnapshot {
|
||||||
Name: string
|
Name: string
|
||||||
Created: string
|
Created: string
|
||||||
|
|||||||