Implement dark industrial theme, Project Manager, and JWT authentication
- Add dark industrial theme with brand color #c20600 and CSS variables - Create Header component with version display and user dropdown menu - Create StatusBar with connection indicator and project/session display - Create ProjectManager page with CRUD, list view, and inspector - Add JWT Bearer token to API requests for authenticated endpoints - Add project API endpoints (GET/POST/PUT/DELETE /api/v1/projects) - Add ProjectService methods: findById, findBySlug, delete - Add unit tests for project API endpoints - Add Playwright E2E tests for projects flow - Update UI design guide with implementation details - Fix: empty JSON body parsing in web-app.ts middleware
This commit is contained in:
parent
f900ecb3dd
commit
2e9571a74e
@ -4,282 +4,285 @@ Gadget Code is an Agentic Integrated Development Environment (AIDE). It is a too
|
|||||||
|
|
||||||
The brand color is #c20600, a rich red. It is to be used when printing Gadget Code.
|
The brand color is #c20600, a rich red. It is to be used when printing Gadget Code.
|
||||||
|
|
||||||
The Gadget Code IDE (hereafter: IDE) is an HTML5 web app building using the latest stable ReactJS and Tailwind CSS. The IDE delivers only industrial and purposeful dark theme that uses mostly blacks, near-black, and dark gray with light gray and brand color highlights.
|
The Gadget Code IDE (hereafter: IDE) is an HTML5 web app building using the latest stable ReactJS 19 and Tailwind CSS 4. The IDE delivers only industrial and purposeful dark theme that uses mostly blacks, near-black, and dark gray with light gray and brand color highlights.
|
||||||
|
|
||||||
Information-dense status and property panels. Tight margins and padding.
|
Information-dense status and property panels. Tight margins and padding.
|
||||||
|
|
||||||
This is NOT a consumer news blog with giant bloated hero sections, etc. We prefer flat material design with thoughtful borders that help the eye find the information and focus on it - not distract from it.
|
This is NOT a consumer news blog with giant bloated hero sections, etc. We prefer flat material design with thoughtful borders that help the eye find the information and focus on it - not distract from it.
|
||||||
|
|
||||||
|
## Theme Colors
|
||||||
|
|
||||||
|
The IDE uses the following color palette (CSS custom properties):
|
||||||
|
|
||||||
|
```
|
||||||
|
--color-brand: #c20600
|
||||||
|
--color-bg-primary: #0a0a0a (main background - pure black)
|
||||||
|
--color-bg-secondary: #121212 (panels, sidebars)
|
||||||
|
--color-bg-tertiary: #1a1a1a (cards, dropdowns)
|
||||||
|
--color-bg-elevated: #202020 (hover states)
|
||||||
|
--color-text-primary: #d4d4d4 (main text)
|
||||||
|
--color-text-secondary: #a3a3a3 (muted text)
|
||||||
|
--color-text-muted: #737373 (disabled, hints)
|
||||||
|
--color-border-subtle: #1a1a1a (subtle dividers)
|
||||||
|
--color-border-default: #2a2a2a (default borders)
|
||||||
|
--color-border-highlight: #3a3a3a (emphasis borders)
|
||||||
|
```
|
||||||
|
|
||||||
|
Font stack:
|
||||||
|
- Primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif
|
||||||
|
- Code/Retro: Courier New, Courier, monospace
|
||||||
|
|
||||||
## View Layout (Whole Application)
|
## View Layout (Whole Application)
|
||||||
|
|
||||||
The root element is given fixed positioning to fill the browser view entirely, and presents a full-width/full-height Flex column that spans the entire view.
|
The root element is given fixed positioning to fill the browser view entirely, and presents a full-width/full-height Flex column that spans the entire view.
|
||||||
|
|
||||||
The top row is the header bar. The 2nd row is the content area. The 3rd/bottom row is the status bar.
|
```
|
||||||
|
#root {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The top row is the header bar (48px). The 2nd row is the content area (flex-1). The 3rd/bottom row is the status bar (32px).
|
||||||
|
|
||||||
### Header Bar
|
### Header Bar
|
||||||
|
|
||||||
The header bar is basically our "window title bar" like for a desktop application, and will display:
|
The header bar is basically our "window title bar" like for a desktop application:
|
||||||
|
|
||||||
```
|
```
|
||||||
GADGET CODE v1.0.0 robc
|
GADGET CODE v1.0.0 [Username ▼]
|
||||||
```
|
```
|
||||||
|
|
||||||
It displays the application title and current version (available from `env.pkg.version`). It then displays the User menu top-right, giving the User access to their Settings, Logout action, etc.
|
- Left: Application title + version using Courier New font
|
||||||
|
- Right: User dropdown menu (display name, click to open)
|
||||||
|
- Settings (placeholder)
|
||||||
|
- Logout
|
||||||
|
|
||||||
|
Implementation: `frontend/src/components/Header.tsx`
|
||||||
|
|
||||||
### Status Bar
|
### Status Bar
|
||||||
|
|
||||||
|
The status bar displays:
|
||||||
|
|
||||||
```
|
```
|
||||||
Ready. project-slug | PLAN | 🟢
|
Ready. my-project | BUILD | ●
|
||||||
```
|
```
|
||||||
|
|
||||||
The status bar will always display the current status message, or Ready. when there is no message to display. This will expand to fill the width of the bar until we hit the right-side components.
|
- Left: Status message ("Ready." default)
|
||||||
|
- Center-right: Active project slug (when selected), Session mode (PLAN/BUILD/TEST/SHIP/DEV)
|
||||||
|
- Right: Connection indicator (● = connected, animates when healthy)
|
||||||
|
|
||||||
On the right, when the User has a Project open in the IDE, the project's slug will be displayed. When the User is also in a Chat Session, the current ChatSessionMode will be displayed (PLAN, BUILD, TEST, SHIP, DEV).
|
Implementation: `frontend/src/components/StatusBar.tsx`
|
||||||
|
|
||||||
Where i have typed a 🟢 above, the intent is for that to indicate the IDE's connection status to the gadget-code Socket.IO server.
|
|
||||||
|
|
||||||
- 🟢 fully connected, healthy (should animate/strobe/glow)
|
|
||||||
- 🟡 connection problems of any kind (flat/non-animated)
|
|
||||||
- 🔴 connection errors of any kind (should blink/flash and glow when lit)
|
|
||||||
- ⚫ not connected (flat, dull, off)
|
|
||||||
|
|
||||||
This lets the User know the SCOPE of their current work. If they submit a prompt, a drone will execute the prompt in the [Current Project] project.
|
|
||||||
|
|
||||||
The User needs this information at all times. So the header bar is persistent.
|
|
||||||
|
|
||||||
### Content Area
|
### Content Area
|
||||||
|
|
||||||
Between the header and status bars filling the width of the view is the Content Area. This will present everything from the multi-mode Home view (auth/no-auth), the Project Manager, the Chat Session View, the Debugger View, etc. ALL application views are rendered to the Content Area.
|
Between the header and status bars is the Content Area. It uses React Router for URL-based navigation:
|
||||||
|
|
||||||
It will usually offer:
|
| Route | View |
|
||||||
|
|-------|------|
|
||||||
|
| `/` | Home (authenticated dashboard or unauthenticated) |
|
||||||
|
| `/projects` | Project Manager (list view) |
|
||||||
|
| `/projects/:slug` | Project Manager (project selected) |
|
||||||
|
| `/projects/new` | New project form |
|
||||||
|
| `/sign-in` | Sign in page |
|
||||||
|
| `/sign-out` | Signs out and redirects to `/` |
|
||||||
|
|
||||||
- A full-width view, such as when using the "Fullscreen File Editor"
|
## Unauthenticated Home View
|
||||||
- Left sidebar and content (Project Manager)
|
|
||||||
- Content and right sidebar (the Chat Session view and File Editor)
|
|
||||||
- Left sidebar, content, and right sidebar (Debugger)
|
|
||||||
|
|
||||||
There are no other layouts. The User will instruct to use one of these layouts. If the User doesn't specify, recommend a layout and ask the User if they agree or would like something different. The User is not allowed to give you an ad-hoc layout. They MUST extend this document first. THEN you can use the new layout.
|
The logged-out home view displays a retro-styled "GADGET CODE" wordmark in ANSI Art style:
|
||||||
|
|
||||||
#### Home View
|
- VGA color palette: #0000AA, #00AA00, #00AAAA, #AA0000, #AA00AA, #AAAA00, #AAAAAA, #555555
|
||||||
|
- Font: Courier New (monospace)
|
||||||
|
- Background: #0a0a0a (pure black)
|
||||||
|
- Boxed with 2px border using border-default color
|
||||||
|
- [Sign In] button styled as `[ Sign In ]` bracket format
|
||||||
|
|
||||||
The home view has two modes:
|
Users do not "sign up" for Gadget Code - accounts are administered via CLI (`pnpm cli`).
|
||||||
|
|
||||||
- Unauthenticated / not logged in
|
Implementation: `frontend/src/pages/Home.tsx` - AnsiLogo component
|
||||||
- Authenticated / logged in
|
|
||||||
|
|
||||||
##### Unauthenticated Home View
|
## Authenticated Home View (Dashboard)
|
||||||
|
|
||||||
The logged-out home view displays a retro-styled "Gadget Code" word type logo rendered reminiscent of early 1990s Bulletin Board Service "ANSI Art" - colored text. Should use the Courier New font in a typical IBM PC VGA color palette on a black background. But the border should be pure CSS, sophisticated, and modern.
|
The authenticated home view presents:
|
||||||
|
|
||||||
Users do not "sign up" for Gadget Code, it is an administered system. A system administrator will use [Gadget Code CLI](../src/web-cli.ts) by running `pnpm cli` to add, remove, and manage User accounts.
|
|
||||||
|
|
||||||
##### Authenticated Home View
|
|
||||||
|
|
||||||
```
|
```
|
||||||
HEADER BAR
|
[Welcome, Username!]
|
||||||
--------------------------------------------------------------------------------
|
Your dashboard is under construction.
|
||||||
User Dashboard (reserved content area)------------------------|Calendar+Clock--|
|
Select a project or chat session from the sidebar to get started.
|
||||||
| |
|
|
||||||
This area will eventually present project statistics, AI use | |
|
[Open Project Manager]
|
||||||
statistics, project analytics, etc. | |
|
|
||||||
|Projects--------|
|
+---------------------------+-----------------------+
|
||||||
For now, it is left empty. | |
|
| | Clock (AM/PM) |
|
||||||
| |
|
| Reserved for future | Date |
|
||||||
| |
|
| dashboard content +-----------------------+
|
||||||
| |
|
| | Projects [+] |
|
||||||
|Recent Chats----|
|
| | - project-1 |
|
||||||
| |
|
| | - project-2 |
|
||||||
| |
|
| +-----------------------+
|
||||||
| |
|
| | Drones |
|
||||||
|Drones----------|
|
| | - drone-alpha ● |
|
||||||
| |
|
| | - drone-beta ○ |
|
||||||
| |
|
| +-----------------------+
|
||||||
--------------------------------------------------------------------------------
|
| | Recent Chats |
|
||||||
STATUS BAR
|
| | (loading...) |
|
||||||
|
+---------------------------+-----------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
The authenticated/logged-in home view is the Home Dashboard, which reserves a left content area, and provides a right sidebar that we populate with the following components:
|
Components:
|
||||||
|
1. Clock - local time in AM/PM format, current date
|
||||||
|
2. Projects list - links to `/projects/:slug`, [+]/link navigates to `/projects`
|
||||||
|
3. Drones list - status indicator (green=available, yellow=busy, gray=offline)
|
||||||
|
4. Recent Chats - loading placeholder (stubbed, requires ChatSession API)
|
||||||
|
|
||||||
1. Calendar View with current local time (AM/PM format)
|
Implementation: `frontend/src/pages/Home.tsx` - DashboardSidebar component
|
||||||
2. Project List (sorted alphabetically)
|
|
||||||
3. Registered Drones (sorted alphabetically, active/busy first)
|
|
||||||
4. Recent chat sessions (title, project name, # turns, # tool calls)
|
|
||||||
|
|
||||||
The User needs to be able to navigate to the Project Manager from the authenticated home. That is where they can create new projects, open existing projects, and otherwise manage projects (rename, delete, edit info, etc).
|
## Project Manager View
|
||||||
|
|
||||||
The User can select a Project to open the Project Manager with that Project open.
|
The Project Manager presents:
|
||||||
|
|
||||||
The User can select a Recent Chat to open the Chat Session View with that chat session loaded and open.
|
|
||||||
|
|
||||||
The User can select a Drone to open the Drone Inspector to view a drone's current status and recent activity such as ChatHistory records (chat session turns), and the forthcoming DroneOperation logs - which are operations taken by a Drone while managing projects (git clones of new repos, project synchronization processes, etc.).
|
|
||||||
|
|
||||||
#### Project Manager View
|
|
||||||
|
|
||||||
```
|
```
|
||||||
HEADER BAR
|
+---------------------------+------------------------------------+
|
||||||
--------------------------------------------------------------------------------
|
| [New Project] | Project Inspector |
|
||||||
|[New Project] | Project Inspector |Chat Sessions |
|
|---------------------------| |
|
||||||
|----------------|-------------------------------------------------------------|
|
| Projects (2) | Select a project to view details |
|
||||||
|Project List | |This list will |
|
| [project-one ] | or create a new project |
|
||||||
| | |populate with |
|
| [project-two ] | |
|
||||||
| | |chat sessions |
|
| | |
|
||||||
| | |for the |
|
+---------------------------+------------------------------------+
|
||||||
| | |selected |
|
|
||||||
| | |project. |
|
|
||||||
| | | |
|
|
||||||
| | | |
|
|
||||||
| | | |
|
|
||||||
| | | |
|
|
||||||
| | | |
|
|
||||||
| | | |
|
|
||||||
| | | |
|
|
||||||
| | | |
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
STATUS BAR
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The User clicks the [New Project] button to create [Project](../src/models/project.ts) records for tracking a software development project in Gadget Code. A Developer only cares about their own projects. It's not a security violation for a User to see another User's projects. It's just superflous information. John doesn't care about Nancy's projects. John works on John's projects, and he owns them.
|
When a project is selected:
|
||||||
|
|
||||||
Projects have a display name (name), slug, git URL, and some metadata. The User enters a project's name, slug, git URL when creating a project. Project slugs must be unique per-User because they are the name of the project directory in a Drone's workspace.
|
|
||||||
|
|
||||||
When the User selects a project in the Project List, that project is opened in the Project Inspector on the right, and displays all available information for the project in the Project Inspector. Additionally, when a Project is selected in the list, the ChatSession records for that Project are displayed in the Chat Sessions
|
|
||||||
list on the right with the most recent at the top.
|
|
||||||
|
|
||||||
The User can navigate Back to the Authenticated Home view.
|
|
||||||
|
|
||||||
#### Chat Session View
|
|
||||||
|
|
||||||
The Chat Session View presents as follows:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
HEADER BAR
|
+---------------------------+------------------------------------+
|
||||||
--------------------------------------------------------------------------------
|
| [New Project] | Project Inspector |
|
||||||
Work Area-----------------------------------------------------|Session Status--|
|
|---------------------------| |
|
||||||
| |
|
| Projects (2) | Name: project-one |
|
||||||
| |
|
| [project-one ●] | Slug: project-one |
|
||||||
| |
|
| [project-two ] | Git URL: https://github.com/... |
|
||||||
|----------------|
|
| | Status: active |
|
||||||
|TC | FO | SA |
|
| | Created: 2026-04-28 |
|
||||||
|----------------|
|
| | |
|
||||||
| |
|
| | [Delete Project] |
|
||||||
| |
|
| +-------------------------------+
|
||||||
| |
|
| | Chat Sessions |
|
||||||
Log-----------------------------------------------------------|File Browser----|
|
| | (loading...) |
|
||||||
| |
|
+---------------------------+------------------------------------+
|
||||||
| |
|
|
||||||
| |
|
|
||||||
| |
|
|
||||||
| |
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
STATUS BAR
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The user can navigate Back to wherever they came from (Home or Project Manager).
|
Features:
|
||||||
|
- Left sidebar: Project list with [+ New Project] button
|
||||||
|
- Project Inspector: Shows name, slug, gitUrl, status, createdAt
|
||||||
|
- Delete: Confirmation before deletion
|
||||||
|
- Chat Sessions placeholder (requires ChatSession API)
|
||||||
|
|
||||||
The Work Area pane has modes, and can display the Chat View (messages + prompt input), the Expanded Prompt Editor, various Inspectors, and the File Editor. The remainder of the view always presents the Session sidebar, Log, and File Browser.
|
URL-based: `/projects` (list), `/projects/:slug` (selected)
|
||||||
|
|
||||||
##### Chat View
|
Implementation: `frontend/src/pages/ProjectManager.tsx`
|
||||||
|
|
||||||
```
|
### New Project Form
|
||||||
HEADER BAR
|
|
||||||
--------------------------------------------------------------------------------
|
Triggered by [New Project] button or `/projects/new` route:
|
||||||
Chat Messages-------------------------------------------------|Session Status--|
|
|
||||||
| |
|
- Fields: Project Name*, Project Slug*, Git Repository URL
|
||||||
| |
|
- Slug tip: "Unique identifier for the project directory"
|
||||||
| |
|
- Submit: "Create Project" button (brand color)
|
||||||
|----------------|
|
- Cancel: Returns to project list
|
||||||
|TC | FO | SA |
|
|
||||||
|----------------|
|
## Authentication
|
||||||
| |
|
|
||||||
| |
|
### Frontend Token Management
|
||||||
[Prompt Text Input bar ][Expand][Send] | |
|
|
||||||
Log-----------------------------------------------------------|File Browser----|
|
- Token stored in localStorage: `dtp_auth_token`
|
||||||
| |
|
- User stored in localStorage: `dtp_user`
|
||||||
| |
|
- Project stored in localStorage: `dtp_current_project`
|
||||||
| |
|
|
||||||
| |
|
### API Requests
|
||||||
| |
|
|
||||||
--------------------------------------------------------------------------------
|
All API requests include the JWT in the Authorization header:
|
||||||
STATUS BAR
|
|
||||||
|
```typescript
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
```
|
```
|
||||||
|
|
||||||
If the User selects [Expand] then the entire Work Area becomes an enlarged multi-line <textarea>. Enter inserts a newline. Ctrl+Enter sends the Prompt and closes the expanded Prompt Editor. This lets the User compose complex and highly-detailed prompts for the Gadget Code agent to process. The User can also press Esc or click a close button to exitt expanded prompt editor without sending the prompt. When closing the expanded prompt editor, the user's prompt is cleared and the regular text input bar is emptied.
|
### Backend Session Restoration
|
||||||
|
|
||||||
The user uses the Prompt Text Input Bar for shorter prompts, and for sending course-correcting prompts while the agent is working. The input is not disabled while the agent is working - the User can send the agent messages and they will be queued for delivery when possible at the next iteration of the Agentic Workflow Loop in the drone.
|
The backend restores user sessions from:
|
||||||
|
1. `Authorization: Bearer <token>` header (JWT)
|
||||||
|
2. Express session (fallback)
|
||||||
|
|
||||||
When the User sends a prompt, a new Turn is created in the Scrolling Chat Messages. The User's prompt is added to the turn, and the Agent's response is added to the turn. The Agent's response will receive thinking and response text updtes as they stream in from the agent.
|
The `requireUser()` middleware ensures endpoints are authenticated.
|
||||||
|
|
||||||
The Session Status sidebar panel displays the name of the current ChatSession, the ID of the chat session, the service provider and model being used, the live value of API API response values done and done_reason, and a tabbed view with the following three tabs:
|
## Security
|
||||||
|
|
||||||
1. Tool Calls (shown as TC in diagram)
|
### Password Field Handling
|
||||||
2. File Operations (shown as FO in diagram)
|
|
||||||
3. Subagents (shown as SA in diagram)
|
|
||||||
|
|
||||||
As drones send Tool Call reports, their data is appended to the Tool Calls list. As Drones report File Operations, their data is appended to the File Operations list. Subagent processing is slightly more complex.
|
Password credentials are NEVER exposed:
|
||||||
|
- Mongoose `select: false` on password fields in models
|
||||||
|
- Population uses `select: "-passwordSalt -password"` to exclude from queries
|
||||||
|
- Frontend User interface only includes: `_id`, `email`, `displayName`, `flags`
|
||||||
|
|
||||||
When a Drone spawns a subagent to perform a task, it will send a subagent-create message. The subagent will then send tool call and file operation events of its own, which are all filed against the subagent entry in the list. The drone eventually sends subagent-done with a report, which is all saved by gadget-code, and able to be viewed here.
|
## Chat Session View (Planned)
|
||||||
|
|
||||||
##### Subagent Inspector
|
The Chat Session View presents:
|
||||||
|
|
||||||
If the User selects an entry in the Subagents list, it opens the Subagent Inspector to cover the Chat area (messages AND input) with the Subagent Inspector, letting the user see the subagent's thinking, response, tool call and file operation information. This resembles a Chat View without a Prompt Input bar, and exists within the Chat Messages area, and is composed as follows:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Subagent Observer---------------------------------------------|Session Status--|
|
Work Area | Session Status
|
||||||
thinking: text content |TC | FO | |
|
----------------------------------------------|---------------
|
||||||
response: text content |-----------------| |
|
Chat Messages | Chat: name
|
||||||
thinking: ... | | |
|
| ID: ...
|
||||||
response: ... | |----------------|
|
| Model: ...
|
||||||
| |TC | FO | SA |
|
------------------------------------------|---------------
|
||||||
| |----------------|
|
[Prompt input ][Expand][Send] | TC | FO | SA
|
||||||
| | |
|
----------------------------------------------|---------------
|
||||||
|
Log | Files
|
||||||
|
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The User needs a close gadget to exit the Subagent Inspector. The User can open a Subagent Inspector while the subagent is running, and view updates in this view as they arrive.
|
Implemented components:
|
||||||
|
- Chat Messages (stubbed)
|
||||||
##### File Editor
|
- Prompt Input (stubbed)
|
||||||
|
- Session Status sidebar (stubbed)
|
||||||
When working in a Chat Session with the Gadget Code AI agent, and the Agent is NOT working on the project, the User can open a file in the Files pane to view (and edit) the file fullscreen. This just means the file will occupy the entire Content Area. The header/status bars always remain.
|
- File Browser (stubbed)
|
||||||
|
|
||||||
```
|
|
||||||
HEADER BAR
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
ACE Editor--------------------------------------|File Sidebar-|Session Status--|
|
|
||||||
| | |
|
|
||||||
| | |
|
|
||||||
| | |
|
|
||||||
| | |
|
|
||||||
| | |
|
|
||||||
| | |
|
|
||||||
|-------------| |
|
|
||||||
|[C] [S][B][C]| |
|
|
||||||
Log-----------------------------------------------------------|Files-----------|
|
|
||||||
| |
|
|
||||||
| |
|
|
||||||
| |
|
|
||||||
| |
|
|
||||||
| |
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
STATUS BAR
|
|
||||||
```
|
|
||||||
|
|
||||||
We will integrate the ACE editor. ACE offers "themes" for color syntax highlighting and appearance. The File Editor should, therefore, be as neutral as possible to stay out of the way of the code - which is the FOCUS of the User when using this view.
|
|
||||||
|
|
||||||
The sidebar is for selecting the ACE theme, viewing metadata about the file (size, type, git annotation/history, etc.). A User Actions bar is located at the bottom of the right-side sidebar offering:
|
|
||||||
|
|
||||||
```
|
|
||||||
[C]lose [S]ave [B]uild [C]ommit
|
|
||||||
```
|
|
||||||
|
|
||||||
If the User selects Close and the file is dirty, we confirm that they want to discard their edit and close the file. If the User clicks Save, the file is written to disk via the Drone we've locked to the ChatSession.
|
|
||||||
|
|
||||||
If the User selects [Build], the build command is sent to the Drone. The Log will display build output for the User's review.
|
|
||||||
|
|
||||||
## Mobile Devices and Responsive Design
|
## Mobile Devices and Responsive Design
|
||||||
|
|
||||||
Nope. Gadget Code is a desktop workstation experience with one or more high-resolution displays, keyboard, mouse, webcam, microphone, and ample networking. There is no planned support for mobile devices. The entire focus is on delivering the absolute best DESKTOP WORKSTATION experience possible with a professional fit, finish, look, and feel.
|
Nope. Gadget Code is a desktop workstation experience with one or more high-resolution displays, keyboard, mouse, webcam, microphone, and ample networking. There is no planned support for mobile devices.
|
||||||
|
|
||||||
Buttons can be normal, not bloated for finger use. Checkboxes can be normal. There are no UI gestures. Gadget Code is an IDE intended to make the agentic programming experience observable to scientists. It is not a plaything.
|
Buttons are normal-sized, not bloated for finger use.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
The IDE wants to be as accessible as possible, providing aria tags as much as possible, to make use of the application as accessible as possible for those with physical impairments. HTML5 provides wonderful tools for enabling those with disabilities, and we will do our best to support those standards. Our process is to buld first and focus on the build with an eye toward accessibility, then take a 2nd pass after the build is accepted, and add the accessibility. The User will ask you to perform an accessibility pass when desired, and you can recommend, "Now would be a good time to take an accessibility pass over the UI," and get the User's approval.
|
The IDE wants to be as accessible as possible, providing aria tags as much as possible. Build first with an eye toward accessibility, then take a 2nd pass after the build is accepted to add accessibility enhancements.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Location: `tests/**/*.test.ts` (excluding `tests/e2e/`)
|
||||||
|
|
||||||
|
Run: `pnpm test`
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
Location: `tests/e2e/**/*.test.ts`
|
||||||
|
|
||||||
|
Run: `npx playwright test`
|
||||||
|
|
||||||
|
Prerequisites: Running `pnpm dev:backend` and `pnpm dev:frontend`
|
||||||
|
|
||||||
|
Target: `https://code-dev.g4dge7.com:5174`
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd gadget-code
|
||||||
|
pnpm dev:backend # Backend on https://localhost:3443
|
||||||
|
pnpm dev:frontend # Frontend on https://localhost:5174
|
||||||
|
pnpm build # Build all (backend -> dist/, frontend -> frontend/dist/)
|
||||||
|
pnpm test # Unit tests
|
||||||
|
npx playwright test # E2E tests
|
||||||
|
```
|
||||||
@ -1,14 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, createContext, useContext } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { User } from './lib/api';
|
import { User } from './lib/api';
|
||||||
import { socketClient } from './lib/socket';
|
import { socketClient } from './lib/socket';
|
||||||
import Navbar from './components/Navbar';
|
import Header from './components/Header';
|
||||||
|
import StatusBar from './components/StatusBar';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
|
import ProjectManager from './pages/ProjectManager';
|
||||||
import SignIn from './pages/SignIn';
|
import SignIn from './pages/SignIn';
|
||||||
import SignUp from './pages/SignUp';
|
import SignUp from './pages/SignUp';
|
||||||
|
|
||||||
const TOKEN_KEY = 'dtp_auth_token';
|
const TOKEN_KEY = 'dtp_auth_token';
|
||||||
const USER_KEY = 'dtp_user';
|
const USER_KEY = 'dtp_user';
|
||||||
|
const PROJECT_KEY = 'dtp_current_project';
|
||||||
|
|
||||||
function getStoredUser(): User | null {
|
function getStoredUser(): User | null {
|
||||||
try {
|
try {
|
||||||
@ -27,17 +30,49 @@ function setStoredUser(user: User | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStoredProject(): string | null {
|
||||||
|
return localStorage.getItem(PROJECT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredProject(slug: string | null) {
|
||||||
|
if (slug) {
|
||||||
|
localStorage.setItem(PROJECT_KEY, slug);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(PROJECT_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppContextType {
|
||||||
|
user: User | null;
|
||||||
|
currentProject: string | null;
|
||||||
|
setCurrentProject: (slug: string | null) => void;
|
||||||
|
onSignOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
|
|
||||||
|
export function useAppContext(): AppContextType {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
if (!ctx) throw new Error('useAppContext must be used within App provider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [currentProject, setCurrentProject] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = getStoredUser();
|
const storedUser = getStoredUser();
|
||||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const storedProject = localStorage.getItem(PROJECT_KEY);
|
||||||
if (storedUser && storedToken) {
|
if (storedUser && storedToken) {
|
||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
socketClient.connect(storedToken);
|
socketClient.connect(storedToken);
|
||||||
}
|
}
|
||||||
|
if (storedProject) {
|
||||||
|
setCurrentProject(storedProject);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -50,25 +85,37 @@ export default function App() {
|
|||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(PROJECT_KEY);
|
||||||
setStoredUser(null);
|
setStoredUser(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setCurrentProject(null);
|
||||||
socketClient.disconnect();
|
socketClient.disconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetCurrentProject = (slug: string | null) => {
|
||||||
|
setStoredProject(slug);
|
||||||
|
setCurrentProject(slug);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-bg">
|
<div className="h-screen flex items-center justify-center bg-bg-primary">
|
||||||
<div className="text-text-muted">Loading...</div>
|
<div className="text-text-muted">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AppContext.Provider value={{ user, currentProject, setCurrentProject: handleSetCurrentProject, onSignOut: handleSignOut }}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen bg-bg flex flex-col">
|
<div className="h-screen flex flex-col bg-bg-primary">
|
||||||
<Navbar user={user} onSignOut={handleSignOut} />
|
<Header user={user} onSignOut={handleSignOut} />
|
||||||
|
<main className="flex-1 flex overflow-hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home user={user} />} />
|
<Route path="/" element={<Home user={user} />} />
|
||||||
|
<Route path="/projects" element={<ProjectManager user={user} />} />
|
||||||
|
<Route path="/projects/new" element={<ProjectManager user={user} />} />
|
||||||
|
<Route path="/projects/:slug" element={<ProjectManager user={user} />} />
|
||||||
<Route
|
<Route
|
||||||
path="/sign-in"
|
path="/sign-in"
|
||||||
element={
|
element={
|
||||||
@ -90,7 +137,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<StatusBar projectSlug={currentProject} />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
43
gadget-code/frontend/src/components/Clock.tsx
Normal file
43
gadget-code/frontend/src/components/Clock.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Clock() {
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||||
|
Clock
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-lg text-text-primary">
|
||||||
|
{formatTime(time)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-text-secondary">
|
||||||
|
{formatDate(time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
gadget-code/frontend/src/components/Header.tsx
Normal file
78
gadget-code/frontend/src/components/Header.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { User } from '../lib/api';
|
||||||
|
|
||||||
|
const APP_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
user: User | null;
|
||||||
|
onSignOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ user, onSignOut }: HeaderProps) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-header flex items-center justify-between px-4 bg-bg-secondary border-b border-border-subtle shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/" className="text-lg font-mono font-bold tracking-wider text-text-primary">
|
||||||
|
GADGET CODE v{APP_VERSION}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-text-secondary hover:text-text-primary hover:bg-bg-tertiary rounded transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{user.displayName}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${menuOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 w-40 border border-border-default rounded shadow-lg z-50"
|
||||||
|
style={{ backgroundColor: '#1a1a1a' }}
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
onSignOut();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
gadget-code/frontend/src/components/StatusBar.tsx
Normal file
76
gadget-code/frontend/src/components/StatusBar.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { socketClient } from '../lib/socket';
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'connected' | 'connecting' | 'error' | 'disconnected';
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
statusMessage?: string;
|
||||||
|
projectSlug?: string;
|
||||||
|
sessionMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionIndicator({ status }: { status: ConnectionStatus }) {
|
||||||
|
const statusStyles = {
|
||||||
|
connected: 'text-green-500 animate-pulse',
|
||||||
|
connecting: 'text-yellow-500',
|
||||||
|
error: 'text-red-500 animate-pulse',
|
||||||
|
disconnected: 'text-gray-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcons = {
|
||||||
|
connected: '●',
|
||||||
|
connecting: '●',
|
||||||
|
error: '●',
|
||||||
|
disconnected: '●',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${statusStyles[status]} text-lg leading-none`}>
|
||||||
|
{statusIcons[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBar({ statusMessage = 'Ready.', projectSlug, sessionMode }: StatusBarProps) {
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateStatus = () => {
|
||||||
|
if (socketClient.connected) {
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateStatus();
|
||||||
|
|
||||||
|
socketClient.on('connect', updateStatus);
|
||||||
|
socketClient.on('disconnect', updateStatus);
|
||||||
|
socketClient.on('error', () => setConnectionStatus('error'));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socketClient.off('connect', updateStatus);
|
||||||
|
socketClient.off('disconnect', updateStatus);
|
||||||
|
socketClient.off('error', updateStatus);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="h-status flex items-center justify-between px-4 bg-bg-secondary border-t border-border-subtle text-sm text-text-muted shrink-0">
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{projectSlug && (
|
||||||
|
<span className="text-text-secondary">{projectSlug}</span>
|
||||||
|
)}
|
||||||
|
{sessionMode && (
|
||||||
|
<span className="text-text-secondary font-mono">{sessionMode}</span>
|
||||||
|
)}
|
||||||
|
<ConnectionIndicator status={connectionStatus} />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,17 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #0f0f12;
|
--color-brand: #c20600;
|
||||||
--color-bg-secondary: #1a1a1f;
|
--color-bg-primary: #0a0a0a;
|
||||||
--color-text: #e4e4e7;
|
--color-bg-secondary: #121212;
|
||||||
--color-text-muted: #a1a1aa;
|
--color-bg-tertiary: #1a1a1a;
|
||||||
--color-primary: #3b82f6;
|
--color-bg-elevated: #202020;
|
||||||
--color-primary-hover: #2563eb;
|
--color-text-primary: #d4d4d4;
|
||||||
--color-border: #27272a;
|
--color-text-secondary: #a3a3a3;
|
||||||
|
--color-text-muted: #737373;
|
||||||
|
--color-border-subtle: #1a1a1a;
|
||||||
|
--color-border-default: #2a2a2a;
|
||||||
|
--color-border-highlight: #3a3a3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -16,14 +20,53 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif';
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg-primary);
|
||||||
color: var(--color-text);
|
color: var(--color-text-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border-highlight);
|
||||||
}
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
const API_BASE = '';
|
const API_BASE = '';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'dtp_auth_token';
|
||||||
|
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
@ -8,16 +10,31 @@ export interface ApiResponse<T = unknown> {
|
|||||||
data?: T;
|
data?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: Record<string, unknown>
|
body?: Record<string, unknown>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
const options: RequestInit = {
|
const options: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -26,13 +43,22 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, options);
|
const response = await fetch(`${API_BASE}${path}`, options);
|
||||||
const json = await response.json() as ApiResponse<T>;
|
const text = await response.text();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Empty response');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var json = JSON.parse(text) as ApiResponse<T>;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid JSON: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!json.success) {
|
if (!json.success) {
|
||||||
throw new Error(json.message || 'Request failed');
|
throw new Error(json.message || 'Request failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// For auth endpoints that return {success, user, token}, return the full response
|
|
||||||
if (json.token !== undefined && json.user !== undefined) {
|
if (json.token !== undefined && json.user !== undefined) {
|
||||||
return json as T;
|
return json as T;
|
||||||
}
|
}
|
||||||
@ -62,3 +88,23 @@ export interface AuthResponse {
|
|||||||
user: User;
|
user: User;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
_id: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: string;
|
||||||
|
status: 'active' | 'inactive' | 'archived';
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
gitUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectApi = {
|
||||||
|
getAll: () => api.get<Project[]>('/api/v1/projects'),
|
||||||
|
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
|
||||||
|
create: (data: { name: string; slug: string; gitUrl?: string }) =>
|
||||||
|
api.post<Project>('/api/v1/projects', data),
|
||||||
|
update: (id: string, data: Partial<{ name: string; slug: string; gitUrl: string; status: string }>) =>
|
||||||
|
api.put<Project>(`/api/v1/projects/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||||
|
};
|
||||||
@ -1,48 +1,176 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import { api, User } from "../lib/api";
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import type { User, Project } from '../lib/api';
|
||||||
|
import { projectApi } from '../lib/api';
|
||||||
|
import Clock from '../components/Clock';
|
||||||
|
|
||||||
|
const ansiColors = [
|
||||||
|
'#0000AA', '#00AA00', '#00AAAA', '#AA0000',
|
||||||
|
'#AA00AA', '#AAAA00', '#AAAAAA', '#555555',
|
||||||
|
];
|
||||||
|
|
||||||
|
function AnsiLogo() {
|
||||||
|
const word = 'GADGET CODE';
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex font-mono text-5xl tracking-wider"
|
||||||
|
style={{ textShadow: '2px 2px 4px rgba(0,0,0,0.8)' }}
|
||||||
|
>
|
||||||
|
{word.split('').map((char, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-block"
|
||||||
|
style={{ color: ansiColors[i % ansiColors.length] }}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-text-muted font-mono text-sm">
|
||||||
|
Agentic Engineering IDE v1.0.0
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 border-2 border-border-default p-6 rounded bg-bg-secondary">
|
||||||
|
<div className="font-mono text-text-secondary text-sm">
|
||||||
|
<div className="mb-2 text-text-muted">// SYSTEM READY</div>
|
||||||
|
<div className="mb-4">Please sign in to continue.</div>
|
||||||
|
<div className="text-text-muted mb-4">
|
||||||
|
Accounts are administered. Contact your administrator for access.
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/sign-in"
|
||||||
|
className="inline-block px-4 py-2 border border-border-highlight text-text-primary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
|
||||||
|
>
|
||||||
|
[ Sign In ]
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardSidebarProps {
|
||||||
|
onNavigate: (view: string, data?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSidebar({ onNavigate }: DashboardSidebarProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const data = await projectApi.getAll();
|
||||||
|
setProjects(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load projects', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 border-l border-border-subtle bg-bg-secondary overflow-y-auto">
|
||||||
|
<Clock />
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-border-subtle">
|
||||||
|
<div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||||
|
<span>Projects</span>
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
className="text-brand hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
[+]
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-text-muted px-2">Loading...</p>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted px-2">No projects yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project._id}
|
||||||
|
onClick={() => navigate(`/projects/${project.slug}`)}
|
||||||
|
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded truncate transition-colors"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-border-subtle">
|
||||||
|
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||||
|
Drones
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-text-muted px-2">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-border-subtle">
|
||||||
|
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||||
|
Recent Chats
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-text-muted px-2">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface HomeProps {
|
interface HomeProps {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ user }: HomeProps) {
|
export default function Home({ user }: HomeProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center bg-bg">
|
<div className="flex-1 flex items-center justify-center bg-bg-primary p-8">
|
||||||
<div className="text-center max-w-lg p-8">
|
<AnsiLogo />
|
||||||
<h1 className="text-4xl font-bold mb-4">Gadget Code</h1>
|
|
||||||
<p className="text-xl text-text-muted mb-8">
|
|
||||||
Agentic Engineering IDE
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-4 justify-center">
|
|
||||||
{/* <a
|
|
||||||
href="/sign-up"
|
|
||||||
className="px-6 py-3 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Sign Up Today!
|
|
||||||
</a> */}
|
|
||||||
<a
|
|
||||||
href="/sign-in"
|
|
||||||
className="px-6 py-3 border border-border hover:bg-bg-secondary rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center bg-bg">
|
<div className="flex-1 flex bg-bg-primary">
|
||||||
<div className="text-center max-w-lg p-8">
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
<h1 className="text-3xl font-bold mb-4">
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-semibold mb-4">
|
||||||
Welcome, {user.displayName}!
|
Welcome, {user.displayName}!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-text-muted">
|
<p className="text-text-secondary mb-4">
|
||||||
And then this is where you write some code to build an app.
|
Your dashboard is under construction.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-text-muted text-sm mb-6">
|
||||||
|
Select a project or chat session from the sidebar to get started.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
className="px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
|
||||||
|
>
|
||||||
|
Open Project Manager
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DashboardSidebar onNavigate={(view) => {
|
||||||
|
if (view === 'project' && typeof navigate === 'function') {
|
||||||
|
navigate('/projects');
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
277
gadget-code/frontend/src/pages/ProjectManager.tsx
Normal file
277
gadget-code/frontend/src/pages/ProjectManager.tsx
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import type { User, Project } from '../lib/api';
|
||||||
|
import { projectApi } from '../lib/api';
|
||||||
|
|
||||||
|
interface ProjectManagerProps {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSuccess: () => void }) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [gitUrl, setGitUrl] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name || !slug) {
|
||||||
|
setError('Name and slug are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await projectApi.create({ name, slug, gitUrl: gitUrl || undefined });
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create project');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<h2 className="text-xl font-semibold mb-6">Create New Project</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-secondary mb-1">Project Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||||
|
placeholder="My Project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-secondary mb-1">Project Slug *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||||
|
placeholder="my-project"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">Unique identifier for the project directory</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-secondary mb-1">Git Repository URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gitUrl}
|
||||||
|
onChange={(e) => setGitUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating...' : 'Create Project'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectInspector({ project, onDelete }: { project: Project; onDelete: () => void }) {
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Are you sure you want to delete this project? This cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await projectApi.delete(project._id);
|
||||||
|
onDelete();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete project', err);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Project Inspector</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-text-muted">Name</div>
|
||||||
|
<div className="text-text-primary">{project.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-text-muted">Slug</div>
|
||||||
|
<div className="font-mono text-text-primary">{project.slug}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-text-muted">Git URL</div>
|
||||||
|
<div className="text-text-primary font-mono text-sm">
|
||||||
|
{project.gitUrl || <span className="text-text-muted">Not configured</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-text-muted">Status</div>
|
||||||
|
<div className="text-text-primary capitalize">{project.status}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-text-muted">Created</div>
|
||||||
|
<div className="text-text-primary">
|
||||||
|
{new Date(project.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-border-subtle">
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 border border-red-600 text-red-500 rounded hover:bg-red-900/20 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete Project'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Chat Sessions</h3>
|
||||||
|
<div className="text-text-muted text-sm">
|
||||||
|
No chat sessions yet. Open a chat to start working on this project.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { slug } = useParams();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (slug && projects.length > 0) {
|
||||||
|
const found = projects.find((p) => p.slug === slug);
|
||||||
|
setSelectedProject(found || null);
|
||||||
|
}
|
||||||
|
}, [slug, projects]);
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const data = await projectApi.getAll();
|
||||||
|
setProjects(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load projects', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectProject = (project: Project) => {
|
||||||
|
navigate(`/projects/${project.slug}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectCreated = () => {
|
||||||
|
setShowNewForm(false);
|
||||||
|
loadProjects();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectDeleted = () => {
|
||||||
|
navigate('/projects');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
||||||
|
<p className="text-text-muted">Please sign in to view projects.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
||||||
|
<p className="text-text-muted">Loading projects...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex bg-bg-primary">
|
||||||
|
<aside className="w-64 border-r border-border-subtle bg-bg-secondary flex flex-col">
|
||||||
|
<div className="p-3 border-b border-border-subtle">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewForm(true)}
|
||||||
|
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
[New Project]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2 px-2">
|
||||||
|
Projects ({projects.length})
|
||||||
|
</div>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted px-2">No projects yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project._id}
|
||||||
|
onClick={() => handleSelectProject(project)}
|
||||||
|
className={`w-full text-left px-2 py-1.5 text-sm rounded truncate transition-colors ${
|
||||||
|
selectedProject?.slug === project.slug
|
||||||
|
? 'bg-brand text-white'
|
||||||
|
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{showNewForm ? (
|
||||||
|
<NewProjectForm
|
||||||
|
onCancel={() => setShowNewForm(false)}
|
||||||
|
onSuccess={handleProjectCreated}
|
||||||
|
/>
|
||||||
|
) : selectedProject ? (
|
||||||
|
<ProjectInspector
|
||||||
|
project={selectedProject}
|
||||||
|
onDelete={handleProjectDeleted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-text-muted py-12">
|
||||||
|
<p className="mb-4">Select a project to view details</p>
|
||||||
|
<p className="text-sm">or create a new project to get started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,13 +5,25 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
bg: '#0f0f12',
|
brand: '#c20600',
|
||||||
'bg-secondary': '#1a1a1f',
|
'bg-primary': '#0a0a0a',
|
||||||
text: '#e4e4e7',
|
'bg-secondary': '#121212',
|
||||||
'text-muted': '#a1a1aa',
|
'bg-tertiary': '#1a1a1a',
|
||||||
primary: '#3b82f6',
|
'bg-elevated': '#202020',
|
||||||
'primary-hover': '#2563eb',
|
'text-primary': '#d4d4d4',
|
||||||
border: '#27272a',
|
'text-secondary': '#a3a3a3',
|
||||||
|
'text-muted': '#737373',
|
||||||
|
'border-subtle': '#1a1a1a',
|
||||||
|
'border-default': '#2a2a2a',
|
||||||
|
'border-highlight': '#3a3a3a',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ['Courier New', 'Courier', 'monospace'],
|
||||||
|
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
'header': '48px',
|
||||||
|
'status': '32px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export class ApiControllerV1 extends DtpController {
|
|||||||
await this.loadChild(path.join(basePath, "auth.js"));
|
await this.loadChild(path.join(basePath, "auth.js"));
|
||||||
await this.loadChild(path.join(basePath, "contact.js"));
|
await this.loadChild(path.join(basePath, "contact.js"));
|
||||||
await this.loadChild(path.join(basePath, "drone.js"));
|
await this.loadChild(path.join(basePath, "drone.js"));
|
||||||
|
await this.loadChild(path.join(basePath, "project.js"));
|
||||||
await this.loadChild(path.join(basePath, "user.js"));
|
await this.loadChild(path.join(basePath, "user.js"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
194
gadget-code/src/controllers/api/v1/project.ts
Normal file
194
gadget-code/src/controllers/api/v1/project.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
// controllers/api/v1/project.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
import { DtpController } from "../../../lib/controller.js";
|
||||||
|
import { ProjectStatus } from "@gadget/api";
|
||||||
|
import projectService from "../../../services/project.js";
|
||||||
|
|
||||||
|
export class ProjectApiControllerV1 extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "ProjectApiControllerV1";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "projectV1";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/projects";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.router.use(this.requireUser());
|
||||||
|
|
||||||
|
this.router.get("/", this.getProjects.bind(this));
|
||||||
|
this.router.post("/", this.createProject.bind(this));
|
||||||
|
this.router.get("/:projectId", this.getProject.bind(this));
|
||||||
|
this.router.put("/:projectId", this.updateProject.bind(this));
|
||||||
|
this.router.delete("/:projectId", this.deleteProject.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjects(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const projects = await projectService.getForUser(req.user);
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: projects,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to get projects", { error });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "failed to get projects",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { name, slug, gitUrl, status } = req.body;
|
||||||
|
|
||||||
|
if (!name || !slug) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "name and slug are required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await projectService.create(req.user, {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
gitUrl,
|
||||||
|
status: status as ProjectStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: project,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to create project", { error });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "failed to create project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const project = await projectService.findById(id);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "project not found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.user.toString() !== req.user._id.toString()) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "access denied",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: project,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to get project", { error });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "failed to get project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProject(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const project = await projectService.findById(id);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "project not found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.user.toString() !== req.user._id.toString()) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "access denied",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, slug, gitUrl, status } = req.body;
|
||||||
|
const updated = await projectService.update(project, {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
gitUrl,
|
||||||
|
status: status as ProjectStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: updated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to update project", { error });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "failed to update project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProject(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const project = await projectService.findById(id);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "project not found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.user.toString() !== req.user._id.toString()) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "access denied",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await projectService.delete(project);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "project deleted",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to delete project", { error });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "failed to delete project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectApiControllerV1;
|
||||||
@ -148,6 +148,18 @@ class ProjectService extends DtpService {
|
|||||||
.populate(this.populateProject);
|
.populate(this.populateProject);
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<IProject | null> {
|
||||||
|
return Project.findById(id).populate(this.populateProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySlug(slug: string, user: IUser): Promise<IProject | null> {
|
||||||
|
return Project.findOne({ slug, user: user._id }).populate(this.populateProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(project: IProject): Promise<void> {
|
||||||
|
await Project.deleteOne({ _id: project._id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ProjectService();
|
export default new ProjectService();
|
||||||
|
|||||||
@ -171,6 +171,10 @@ class DtpWebAppServer implements DtpComponent {
|
|||||||
});
|
});
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
req.rawBody = data;
|
req.rawBody = data;
|
||||||
|
if (!data) {
|
||||||
|
req.body = {};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(data);
|
const parsedData = JSON.parse(data);
|
||||||
req.body = parsedData;
|
req.body = parsedData;
|
||||||
|
|||||||
40
gadget-code/tests/e2e/projects.test.ts
Normal file
40
gadget-code/tests/e2e/projects.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Projects API', () => {
|
||||||
|
test('should sign in and fetch projects', async ({ page }) => {
|
||||||
|
await page.goto('https://code-dev.g4dge7.com:5174/sign-in');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.fill('#email', 'rob@digitaltelepresence.com');
|
||||||
|
await page.fill('#password', 'ionfrali');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
const userData = await page.evaluate(() => localStorage.getItem('dtp_user'));
|
||||||
|
expect(userData).toBeDefined();
|
||||||
|
|
||||||
|
await page.goto('https://code-dev.g4dge7.com:5174/projects');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content).not.toContain('Please sign in to view projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show project manager after sign in', async ({ page }) => {
|
||||||
|
await page.goto('https://code-dev.g4dge7.com:5174/sign-in');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.fill('#email', 'rob@digitaltelepresence.com');
|
||||||
|
await page.fill('#password', 'ionfrali');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
await page.goto('https://code-dev.g4dge7.com:5174/projects');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content).toContain('[New Project]');
|
||||||
|
});
|
||||||
|
});
|
||||||
94
gadget-code/tests/project-api.test.ts
Normal file
94
gadget-code/tests/project-api.test.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
describe('Project API Endpoints', () => {
|
||||||
|
describe('Project API Controller', () => {
|
||||||
|
it('should have project controller registered', () => {
|
||||||
|
const apiV1Path = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
|
expect(fs.existsSync(apiV1Path)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have GET projects route', () => {
|
||||||
|
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
|
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||||
|
expect(content).toContain('getProjects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have POST projects route', () => {
|
||||||
|
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
|
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||||
|
expect(content).toContain('createProject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use requireUser middleware', () => {
|
||||||
|
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
|
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||||
|
expect(content).toContain('requireUser()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not expose password fields in responses', () => {
|
||||||
|
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
|
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||||
|
expect(content).not.toMatch(/passwordSalt/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Project Service', () => {
|
||||||
|
it('should have findById method', () => {
|
||||||
|
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||||
|
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||||
|
expect(content).toContain('findById');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have delete method', () => {
|
||||||
|
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||||
|
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||||
|
expect(content).toContain('async delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have getForUser method', () => {
|
||||||
|
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||||
|
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||||
|
expect(content).toContain('getForUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should populate user with password excluded', () => {
|
||||||
|
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||||
|
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||||
|
expect(content).toContain('-passwordSalt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Frontend API Client', () => {
|
||||||
|
it('should add Authorization header with token', () => {
|
||||||
|
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||||
|
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||||
|
expect(content).toContain('Authorization');
|
||||||
|
expect(content).toContain('Bearer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store token in localStorage', () => {
|
||||||
|
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||||
|
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||||
|
expect(content).toContain('dtp_auth_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have getAll method', () => {
|
||||||
|
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||||
|
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||||
|
expect(content).toContain('getAll');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Interface', () => {
|
||||||
|
it('should not include password fields in User type', () => {
|
||||||
|
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||||
|
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||||
|
const userInterfaceMatch = content.match(/export interface User/);
|
||||||
|
expect(userInterfaceMatch).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user