feat: UI design system - sidebar layout, workspace system, and shell redesign (#77)
* feat: add workspace system and sidebar layout (UI design system Phase A+B) Backend: Workspace model, migration (036), schemas, CRUD API endpoints. Adds workspace_id to trees and categories, seeds 4 default workspaces per account, auto-assigns existing trees by tree_type. Frontend: Complete AppLayout rewrite from top-nav to CSS Grid shell with persistent sidebar + topbar. New components: WorkspaceSwitcher, NavItem, CategoryList, TagCloud, TopBar, Sidebar. Dashboard components: QuickStats, FiltersBar, SectionGroup, TreeListItem, SessionsPanel. WorkspaceStore with localStorage persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add command palette search, dashboard rewrite, and shell height fixes (Phase C) - Add ⌘K command palette with debounced search across flows and sessions - Rewrite QuickStartPage as dashboard with stats, filters, sessions panel - Fix h-[calc(100vh-4rem)] → h-full across all pages for CSS Grid shell - Add active session count badge to sidebar Sessions nav item Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add sidebar collapse, category/tag filtering, and workspace CRUD (Phase D) - Sidebar collapse/expand toggle with icon-only rail mode (persisted) - Sidebar category/tag clicks navigate to /trees with URL params - TreeLibraryPage syncs filters from URL search params bidirectionally - Workspace create modal with icon picker and auto-slug generation - TopBar logo adapts to collapsed sidebar state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Quick Launch modal with actions and recent flows - Zap button opens Quick Launch with create/navigate shortcuts - Shows recent flows for quick session start - Keyboard navigation support (arrows + enter) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add activity notifications panel with session feed - Bell icon shows dot indicator for recent activity - Dropdown panel shows recent sessions with status icons - Links to session detail and sessions list page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: remove workspace system, add pinned flows and label renames Replace workspace system with pinned flows API (pin/unpin/list/reorder). Rename user-facing labels: Tree→Flow, Procedure→Project. Add sidebar nav sub-items for flow type filtering. Remove 11 workspace files, add migrations 037-038, clean all workspace references. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: collapsed sidebar layout scaling and toggle button size Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate auth pages to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate TreeLibraryPage to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate session pages to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate TreeEditorPage to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate TreeNavigationPage to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate session sharing components to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove workspace dropdown animation (dead code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate common components to new design system Migrate 15 components from monochrome glass-card design to purple gradient accent design system tokens (bg-card, border-border, text-foreground, bg-gradient-brand, etc.) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate procedural and step library components to new design system Migrate 10 components from monochrome glass-card design to purple gradient accent design system tokens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate admin pages and components to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate remaining pages to new design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: migrate remaining components to new design system Migrates 38 files: tree-editor forms, session modals, step library, common components, library views, tree preview, and misc UI to use design tokens (bg-card, border-border, text-foreground, bg-accent, bg-gradient-brand) replacing old monochrome patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: keep brand text visible on sidebar collapse, hide sub-items until hover - TopBar: always show "ResolutionFlow" text regardless of sidebar state - NavItem: sub-items (Troubleshooting, Projects) hidden by default, revealed on hover or when a child route is active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #77.
This commit is contained in:
@@ -24,10 +24,10 @@
|
||||
- **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon with gradient). Wordmark: "Resolution" in white + "Flow" in `text-gradient-brand`
|
||||
- **Brand assets:** `brand-assets/` (source SVGs + brand-guide.html), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
|
||||
- **CSS utilities:** `text-gradient-brand`, `bg-gradient-brand`, `bg-gradient-brand-hover` (defined in `tailwind.config.js` and `index.css`)
|
||||
- **Layout:** App shell with persistent sidebar + top bar + main workspace (CSS Grid). See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Workspace system:** Top-level context switcher (Troubleshooting, Procedures, Policies, Finance). Sidebar categories, tags, stats, and content adapt per workspace. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Layout:** App shell with persistent sidebar + top bar + main content (CSS Grid). See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Navigation:** Sidebar nav with type sub-items (All Flows → Troubleshooting / Projects). Pinned flows section for quick access. NO workspace switcher. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
|
||||
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. `tree_type` column values unchanged in DB.
|
||||
- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md)
|
||||
- **Interactive mockup:** `docs/mockups/resolutionflow-workspaces-mockup.html` (open in browser for visual reference)
|
||||
|
||||
**Component styling rules:**
|
||||
- Primary buttons: `bg-gradient-brand` with `shadow-lg shadow-primary/20`, hover lifts with stronger shadow
|
||||
@@ -40,7 +40,7 @@
|
||||
- Cards: `bg-card border-border rounded-xl`, hover brightens border
|
||||
- Section labels: `font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground`
|
||||
|
||||
When adding new pages/components: use "ResolutionFlow" branding, purple gradient accent theme, `bg-card` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, workspace context, and component specs.
|
||||
When adding new pages/components: use "ResolutionFlow" branding, purple gradient accent theme, `bg-card` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows. Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, navigation, and component specs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
608
WORKSPACE-REMOVAL-PLAN.md
Normal file
608
WORKSPACE-REMOVAL-PLAN.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# Workspace Removal & Navigation Refactor — Implementation Plan
|
||||
|
||||
> **Purpose:** Combined implementation plan for removing the workspace system, renaming UI labels (Trees→Flows, Procedures→Projects), adding pinned flows, and restructuring sidebar navigation.
|
||||
> **Source of Truth:** [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) v2
|
||||
> **Date:** February 15, 2026
|
||||
> **Tailwind Version:** v3 only — do not use v4 syntax or patterns (see `package.json` line 56)
|
||||
|
||||
---
|
||||
|
||||
## Why This Change
|
||||
|
||||
Workspaces added unnecessary cognitive overhead for the target MSP audience. UX research (Hick's Law, context-switching studies) shows that at the current product scale (10-15 beta testers, <50 flows per account), a workspace switcher creates friction without organizational benefit. The replacement is a flat navigation model with type sub-items and pinned favorites.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Backend: Add Pinned Flows (ship BEFORE removing workspaces)
|
||||
|
||||
> **Important sequencing:** Add the pinned flows feature first and verify it works. Then remove workspaces in Phase 2. This ensures the replacement feature is stable before tearing out the old one. If both are done in the same session, at minimum run the pinned flows tests before starting workspace removal.
|
||||
|
||||
### 1a. Add Pinned Flows Table
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "add_user_pinned_trees"
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_pinned_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_user_pinned_tree UNIQUE (user_id, tree_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_pinned_trees_user ON user_pinned_trees(user_id);
|
||||
CREATE INDEX idx_user_pinned_trees_tree ON user_pinned_trees(tree_id);
|
||||
```
|
||||
|
||||
### 1b. Pinned Flows API Contract
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| `GET` | `/api/v1/trees/pinned` | List user's pinned flows (ordered by display_order) | Required |
|
||||
| `POST` | `/api/v1/trees/{id}/pin` | Pin a flow to sidebar | Required |
|
||||
| `DELETE` | `/api/v1/trees/{id}/pin` | Unpin a flow from sidebar | Required |
|
||||
| `PATCH` | `/api/v1/trees/pinned/reorder` | Update display_order for all pinned flows | Required |
|
||||
|
||||
**Constraints & Limits:**
|
||||
- Max 15 pinned flows per user (return 409 if exceeded)
|
||||
- `UNIQUE(user_id, tree_id)` — pinning an already-pinned flow returns 200 (idempotent), not an error
|
||||
- Unpinning an already-unpinned flow returns 200 (idempotent)
|
||||
- When a tree is deleted, cascade removes the pin automatically (FK ON DELETE CASCADE)
|
||||
- Pins are per-user, not per-account — each user has their own pinned set
|
||||
|
||||
**Response Shapes:**
|
||||
|
||||
```typescript
|
||||
// GET /api/v1/trees/pinned
|
||||
interface PinnedFlowsResponse {
|
||||
items: PinnedFlow[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PinnedFlow {
|
||||
id: string; // pin record id
|
||||
tree_id: string;
|
||||
tree_name: string;
|
||||
tree_type: 'troubleshooting' | 'procedural';
|
||||
category_emoji?: string;
|
||||
category_name?: string;
|
||||
pinned_at: string; // ISO datetime
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
// POST /api/v1/trees/{id}/pin → returns PinnedFlow
|
||||
// DELETE /api/v1/trees/{id}/pin → returns { success: true }
|
||||
// PATCH /api/v1/trees/pinned/reorder
|
||||
// body: { order: [{ tree_id: string, display_order: number }] }
|
||||
// returns: PinnedFlowsResponse
|
||||
```
|
||||
|
||||
**Error Codes:**
|
||||
- `404` — tree not found or user lacks access
|
||||
- `409` — max pins reached (15)
|
||||
- `200` — idempotent success (pin already exists / already unpinned)
|
||||
|
||||
### 1c. Add Pinned Flows Backend Files
|
||||
|
||||
```
|
||||
CREATE: backend/app/models/user_pinned_tree.py
|
||||
MODIFY: backend/app/models/__init__.py — add UserPinnedTree import
|
||||
MODIFY: backend/app/api/endpoints/trees.py — add pin/unpin/list-pinned/reorder endpoints
|
||||
MODIFY: backend/app/schemas/tree.py — add PinnedFlow schema, add is_pinned to TreeListItem
|
||||
MODIFY: backend/app/api/router.py — register pin routes (nested under trees router)
|
||||
```
|
||||
|
||||
**Verify pinned flows work before proceeding:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
pytest tests/ -k "pin" -v # Run pin-related tests
|
||||
# Manual: POST a pin, GET pinned list, verify response shapes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Backend: Remove Workspace System
|
||||
|
||||
### 2a. New Forward Migration (DO NOT use downgrade path)
|
||||
|
||||
> **⚠️ Critical:** The existing `036_add_workspaces.py` downgrade function drops `tree_categories.color`, which we want to keep. Create a new forward migration instead.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "remove_workspace_system"
|
||||
```
|
||||
|
||||
The migration must:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# 1. Drop workspace_id FK from trees (if column exists)
|
||||
op.drop_constraint('trees_workspace_id_fkey', 'trees', type_='foreignkey')
|
||||
op.drop_column('trees', 'workspace_id')
|
||||
|
||||
# 2. Drop workspace_id FK from tree_categories (if column exists)
|
||||
op.drop_constraint('tree_categories_workspace_id_fkey', 'tree_categories', type_='foreignkey')
|
||||
op.drop_column('tree_categories', 'workspace_id')
|
||||
|
||||
# 3. Drop workspaces table
|
||||
op.drop_table('workspaces')
|
||||
|
||||
# DO NOT drop tree_categories.color — we still use it
|
||||
|
||||
def downgrade():
|
||||
# Recreate workspaces table and FKs if needed (reverse of above)
|
||||
pass
|
||||
```
|
||||
|
||||
### 2b. Delete Workspace Backend Files
|
||||
|
||||
```
|
||||
DELETE: backend/app/api/endpoints/workspaces.py
|
||||
DELETE: backend/app/models/workspace.py
|
||||
DELETE: backend/app/schemas/workspace.py (if exists)
|
||||
```
|
||||
|
||||
### 2c. Clean Up Orphaned Workspace References
|
||||
|
||||
> **⚠️ Important:** Workspace references exist beyond the obvious files. Scrub these:
|
||||
|
||||
| File | What to Remove |
|
||||
|------|---------------|
|
||||
| `backend/app/api/router.py` | Remove workspace route registration |
|
||||
| `backend/app/models/__init__.py` (line 23, 55) | Remove `Workspace` import and `__all__` entry |
|
||||
| `backend/app/models/account.py` (line 18, 49) | Remove `workspaces` relationship on Account model |
|
||||
| `backend/app/models/category.py` (line 40) | Remove `workspace_id` column if present |
|
||||
| `backend/app/models/tree.py` | Remove `workspace_id` column and relationship if present |
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
grep -r "workspace" app/ --include="*.py" -l
|
||||
# Should only return this plan file and alembic migration history — no active code
|
||||
pytest --override-ini="addopts="
|
||||
# All tests must pass
|
||||
```
|
||||
|
||||
**Migration smoke test (run against BOTH clean and existing DB):**
|
||||
|
||||
```bash
|
||||
# Test on existing DB with workspace data:
|
||||
alembic upgrade head
|
||||
# Verify no errors, workspace tables gone, tree_categories.color still exists
|
||||
|
||||
# Test on clean DB (full migration chain):
|
||||
dropdb patherly_test && createdb patherly_test
|
||||
DATABASE_URL=postgresql://...patherly_test alembic upgrade head
|
||||
# Verify clean run through all migrations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend: Remove Workspace System
|
||||
|
||||
### 3a. Move sidebarCollapsed State First
|
||||
|
||||
The `sidebarCollapsed` state currently lives in `workspaceStore.ts`. Move it before deleting:
|
||||
|
||||
```typescript
|
||||
// frontend/src/store/userPreferencesStore.ts — ADD these:
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
// Implementation:
|
||||
sidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
|
||||
toggleSidebar: () => {
|
||||
const next = !get().sidebarCollapsed;
|
||||
localStorage.setItem('sidebar-collapsed', String(next));
|
||||
set({ sidebarCollapsed: next });
|
||||
},
|
||||
```
|
||||
|
||||
> Note: `userPreferencesStore` already uses Zustand persist — verify the localStorage key name doesn't conflict.
|
||||
|
||||
### 3b. Delete Workspace Frontend Files
|
||||
|
||||
```
|
||||
DELETE: frontend/src/store/workspaceStore.ts
|
||||
DELETE: frontend/src/components/workspace/WorkspaceSwitcher.tsx
|
||||
DELETE: frontend/src/components/workspace/WorkspaceCreateModal.tsx
|
||||
DELETE: frontend/src/constants/workspaceLabels.ts
|
||||
DELETE: frontend/src/types/workspace.ts (or remove Workspace type if shared file)
|
||||
DELETE: frontend/src/api/workspaces.ts
|
||||
DELETE: docs/mockups/resolutionflow-workspaces-mockup.html
|
||||
```
|
||||
|
||||
**Before deleting `workspace/` directory**, move these components:
|
||||
|
||||
```
|
||||
MOVE: frontend/src/components/workspace/CategoryList.tsx → frontend/src/components/sidebar/CategoryList.tsx
|
||||
MOVE: frontend/src/components/workspace/TagCloud.tsx → frontend/src/components/sidebar/TagCloud.tsx
|
||||
```
|
||||
|
||||
### 3c. Update Shell Files That Import Workspace Store
|
||||
|
||||
> **⚠️ Important:** These files are marked "keep" in the design system doc but they currently import workspace code. Each needs internal refactoring:
|
||||
|
||||
| File | Lines to Change | Action |
|
||||
|------|----------------|--------|
|
||||
| `AppLayout.tsx` (line 6, 17) | `import { useWorkspaceStore }` | Replace with `import { useUserPreferencesStore }` — use `sidebarCollapsed` and `toggleSidebar` from there |
|
||||
| `TopBar.tsx` (line 6, 20) | `import { useWorkspaceStore }` | Replace with `useUserPreferencesStore` for `sidebarCollapsed`. Remove `getActiveWorkspace()` and `labels` — use static labels or `getFlowLabels()` |
|
||||
| `Sidebar.tsx` (line 4, 111+) | `WorkspaceSwitcher` import and render | Remove workspace switcher component. Add pinned flows section and nav sub-items (Phase 4) |
|
||||
|
||||
### 3d. Clean Up Orphaned Frontend References
|
||||
|
||||
> **⚠️ Important:** Additional workspace references exist beyond the obvious files:
|
||||
|
||||
| File | What to Change |
|
||||
|------|---------------|
|
||||
| `frontend/src/types/index.ts` (line 11, 14) | Remove `Workspace` type export |
|
||||
| `frontend/src/components/layout/QuickLaunch.tsx` | Replace workspace-dependent labels with static "New Flow" / "New Project" |
|
||||
| `frontend/src/components/layout/CommandPalette.tsx` | Replace workspace-dependent search placeholder and result labels |
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
grep -r "workspace" src/ --include="*.ts" --include="*.tsx" -l
|
||||
# Should return nothing except possibly test files or comments
|
||||
grep -r "workspaceStore\|WorkspaceSwitcher\|workspacesApi\|workspaceLabels" src/ -l
|
||||
# Must return nothing
|
||||
npm run build
|
||||
# Must compile clean with zero errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Label Renames (Repo-Wide Audit)
|
||||
|
||||
### 4a. Create Flow Type Labels
|
||||
|
||||
```typescript
|
||||
// frontend/src/constants/flowLabels.ts (NEW — replaces workspaceLabels.ts)
|
||||
|
||||
export interface FlowTypeLabels {
|
||||
navLabel: string;
|
||||
singular: string;
|
||||
plural: string;
|
||||
newButton: string;
|
||||
searchPlaceholder: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const FLOW_TYPE_LABELS: Record<string, FlowTypeLabels> = {
|
||||
all: {
|
||||
navLabel: 'All Flows',
|
||||
singular: 'Flow',
|
||||
plural: 'Flows',
|
||||
newButton: '+ Create Flow',
|
||||
searchPlaceholder: 'Search flows, sessions, tags…',
|
||||
icon: '📦',
|
||||
},
|
||||
troubleshooting: {
|
||||
navLabel: 'Troubleshooting',
|
||||
singular: 'Flow',
|
||||
plural: 'Flows',
|
||||
newButton: '+ New Troubleshooting Flow',
|
||||
searchPlaceholder: 'Search troubleshooting flows…',
|
||||
icon: '🔧',
|
||||
},
|
||||
procedural: {
|
||||
navLabel: 'Projects',
|
||||
singular: 'Project',
|
||||
plural: 'Projects',
|
||||
newButton: '+ New Project',
|
||||
searchPlaceholder: 'Search projects, runbooks…',
|
||||
icon: '📋',
|
||||
},
|
||||
};
|
||||
|
||||
export function getFlowLabels(typeFilter?: string): FlowTypeLabels {
|
||||
if (typeFilter && typeFilter in FLOW_TYPE_LABELS) {
|
||||
return FLOW_TYPE_LABELS[typeFilter];
|
||||
}
|
||||
return FLOW_TYPE_LABELS.all;
|
||||
}
|
||||
```
|
||||
|
||||
### 4b. Label Audit — Files Requiring Changes
|
||||
|
||||
Every instance of old terminology must be found and replaced. This is a **repo-wide pass**, not a targeted find-replace.
|
||||
|
||||
**User-facing label changes:**
|
||||
|
||||
| Old Label | New Label | Notes |
|
||||
|-----------|-----------|-------|
|
||||
| "All Trees" | "All Flows" | Sidebar nav, page titles |
|
||||
| "Tree Editor" | "Flow Editor" | Sidebar nav |
|
||||
| "New Tree" | "Create Flow" | Buttons, menus |
|
||||
| "All Procedures" | "Projects" | Sub-nav item |
|
||||
| "New Procedure" | "New Project" | Buttons, menus |
|
||||
| "Procedure" (singular) | "Project" | Throughout UI |
|
||||
|
||||
**Known files with old labels (non-exhaustive):**
|
||||
|
||||
| File | Old Text | New Text |
|
||||
|------|----------|----------|
|
||||
| `Sidebar.tsx` | "All Trees", "Tree Editor" | "All Flows", "Flow Editor" |
|
||||
| `TreeLibraryPage.tsx` (line 279, 298) | "All Trees", "Tree" references | "All Flows", "Flow" |
|
||||
| `QuickStartPage.tsx` (line 150) | Workspace/procedure labels | Flow/Project labels |
|
||||
| `QuickLaunch.tsx` | "New Tree", "New Procedure" | "Create Flow", "New Project" |
|
||||
| `CommandPalette.tsx` | Search labels | Flow-based labels |
|
||||
| `TopBar.tsx` | Search placeholder | Use `getFlowLabels()` or static "Search flows, sessions, tags…" |
|
||||
|
||||
**Catch-all verification:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
# Find any remaining user-facing "Tree" or "Procedure" labels (excluding variable names and imports)
|
||||
grep -rn '".*Tree.*"' src/ --include="*.tsx" --include="*.ts" | grep -v "import\|//\|interface\|type \|treesApi\|tree_type\|treeId\|TreeNav\|TreeGrid\|TreeList\|TreeTable"
|
||||
grep -rn '".*Procedure.*"' src/ --include="*.tsx" --include="*.ts" | grep -v "import\|//\|type "
|
||||
```
|
||||
|
||||
> **Important:** Only rename **user-facing strings** (UI text, placeholder text, toast messages, page titles). Do NOT rename: variable names (`treesApi`, `TreeListItem`), route paths (`/trees`), database columns (`tree_type`), or API endpoints (`/api/v1/trees`). Internal code can say "tree" — users never see it.
|
||||
|
||||
### 4c. Acceptance Criteria for Label Audit
|
||||
|
||||
- [ ] No user-visible text says "Tree" (except proper nouns or technical docs)
|
||||
- [ ] No user-visible text says "Procedure" — all say "Project"
|
||||
- [ ] Search bar placeholder says "Search flows, sessions, tags…"
|
||||
- [ ] Page title on library page says "Flow Library"
|
||||
- [ ] "+ Create Flow" button on library page (or "+ New Project" when filtered to projects)
|
||||
- [ ] Sidebar nav says "All Flows", "Flow Editor"
|
||||
- [ ] Empty states use "flow" / "project" language
|
||||
- [ ] Toast messages use "flow" / "project" language
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Sidebar: Nav Sub-Items & Pinned Flows UI
|
||||
|
||||
### 5a. Extend NavItem for Children
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/layout/NavItem.tsx — extend props
|
||||
interface NavItemProps {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
badge?: number | 'dot';
|
||||
isActive?: boolean;
|
||||
children?: NavSubItem[]; // NEW
|
||||
}
|
||||
|
||||
interface NavSubItem {
|
||||
href: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Sub-item rendering:**
|
||||
- Indented `pl-9` (past parent icon)
|
||||
- No icon, just text + optional count badge
|
||||
- Font: `text-[0.8125rem] text-muted-foreground`, active: `text-foreground`
|
||||
- Active: `bg-[var(--sidebar-active)]` but without the left gradient bar (only parent gets that)
|
||||
- Sub-items always visible (not collapsible) — there are only 2
|
||||
|
||||
### 5b. Sidebar Structure
|
||||
|
||||
```
|
||||
── PINNED ─────────────────── (collapsible section)
|
||||
📧 Email Delivery Issues (click → start session or go to flow)
|
||||
🔒 AD Account Lockout
|
||||
👤 New User Onboarding
|
||||
───────────────────────────────
|
||||
📊 Dashboard
|
||||
📦 All Flows 47
|
||||
🔧 Troubleshooting 29
|
||||
📋 Projects 18
|
||||
✏️ Flow Editor
|
||||
⏱️ Sessions 4
|
||||
📄 Exports
|
||||
📚 Step Library •
|
||||
───────────────────────────────
|
||||
CATEGORIES
|
||||
● Networking 12
|
||||
● Active Directory 8
|
||||
● Email 11
|
||||
───────────────────────────────
|
||||
POPULAR TAGS
|
||||
[vpn] [dns] [exchange] [onboarding]
|
||||
═══════════════════════════════
|
||||
👥 Team
|
||||
⚙️ Settings
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Clicking "All Flows" → `/trees` (no type filter)
|
||||
- Clicking "Troubleshooting" → `/trees?type=troubleshooting`
|
||||
- Clicking "Projects" → `/trees?type=procedural`
|
||||
- When a sub-item is active, parent "All Flows" stays highlighted (dimmer state)
|
||||
- Badge counts update based on actual tree counts by type
|
||||
|
||||
### 5c. Pinned Flows Section
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/sidebar/PinnedFlowsSection.tsx (NEW)
|
||||
interface PinnedFlowsSectionProps {
|
||||
flows: PinnedFlow[];
|
||||
onFlowClick: (treeId: string) => void;
|
||||
onUnpin: (treeId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Each pinned item: emoji + name (truncated with ellipsis) + hover reveals quick-start button
|
||||
- Right-click context menu: "Start Session", "Edit Flow", "Unpin from Sidebar"
|
||||
- Empty state: "⭐ Pin your most-used flows here" in `text-xs text-muted-foreground`
|
||||
- Section header has collapse chevron
|
||||
- Max 15 items shown; section scrolls internally if needed
|
||||
- Drag-to-reorder (calls `PATCH /api/v1/trees/pinned/reorder`)
|
||||
|
||||
### 5d. Pinned Flows Frontend Wiring
|
||||
|
||||
```
|
||||
CREATE: frontend/src/api/pinnedFlows.ts — pinTree(id), unpinTree(id), listPinned(), reorderPinned(order)
|
||||
CREATE: frontend/src/components/sidebar/PinnedFlowsSection.tsx
|
||||
MODIFY: frontend/src/types/tree.ts (or index.ts) — add is_pinned?: boolean to TreeListItem, add PinnedFlow type
|
||||
MODIFY: frontend/src/api/trees.ts — add is_pinned to list response handling
|
||||
MODIFY: frontend/src/components/layout/Sidebar.tsx — import and render PinnedFlowsSection above nav
|
||||
MODIFY: frontend/src/pages/TreeLibraryPage.tsx — add pin star to flow cards (visible on hover, filled if pinned)
|
||||
MODIFY: flow card three-dot menu — add "Pin to Sidebar" / "Unpin from Sidebar" action
|
||||
```
|
||||
|
||||
**Pin/unpin interaction:**
|
||||
- Toast on pin: "📌 Pinned **{name}** to sidebar"
|
||||
- Toast on unpin: "Unpinned **{name}**"
|
||||
- Library flow cards show subtle star icon on hover; filled star if pinned
|
||||
- Pin star click calls API, optimistically updates UI
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Library Page Cleanup
|
||||
|
||||
### 6a. Remove Folder Sidebar Panel
|
||||
|
||||
> **⚠️ Important:** `TreeLibraryPage.tsx` has deep folder state dependencies (lines 9, 34, 261+). This is not a simple component removal.
|
||||
|
||||
**What to remove:**
|
||||
- `FolderSidebar` component import and rendering (the persistent left panel)
|
||||
- `FolderEditModal` import and state
|
||||
- The CSS column that gives FolderSidebar its own grid track
|
||||
- `mobileFolderOpen` state
|
||||
|
||||
**What to KEEP:**
|
||||
- `selectedFolderId` state — keep for now, wire to a future "Filter by Folder" dropdown
|
||||
- `folders` state and `foldersApi.list()` call — keep data available
|
||||
- `FolderSidebar.tsx` and `FolderEditModal.tsx` files — do not delete, just stop rendering them
|
||||
- All folder-related backend code (models, API, database tables) — untouched
|
||||
|
||||
**Replacement UX (deferred but noted):**
|
||||
- Future: "Filter by Folder" dropdown in the filters bar, or "Move to Folder" in the three-dot menu
|
||||
- For now: folder filtering is simply not visible in the UI. The data model and API remain intact.
|
||||
|
||||
**Layout change:**
|
||||
- Library page becomes full-width within the main content area (no second sidebar column)
|
||||
- Grid goes from `sidebar | folders | content` → `sidebar | content`
|
||||
|
||||
### 6b. Verification
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
# Must compile clean
|
||||
```
|
||||
|
||||
Manual checks:
|
||||
- [ ] Library page is full-width (no left folder panel)
|
||||
- [ ] No JavaScript errors in browser console related to folder state
|
||||
- [ ] Category filtering from sidebar clicks still works
|
||||
- [ ] Tag filtering from sidebar clicks still works
|
||||
|
||||
---
|
||||
|
||||
## Complete File Manifest
|
||||
|
||||
### Delete (11 files)
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `frontend/src/store/workspaceStore.ts` | Replaced by userPreferencesStore (sidebarCollapsed) |
|
||||
| `frontend/src/components/workspace/WorkspaceSwitcher.tsx` | Feature removed |
|
||||
| `frontend/src/components/workspace/WorkspaceCreateModal.tsx` | Feature removed |
|
||||
| `frontend/src/constants/workspaceLabels.ts` | Replaced by flowLabels.ts |
|
||||
| `frontend/src/types/workspace.ts` | Type no longer needed |
|
||||
| `frontend/src/api/workspaces.ts` | API removed |
|
||||
| `backend/app/api/endpoints/workspaces.py` | API removed |
|
||||
| `backend/app/models/workspace.py` | Model removed |
|
||||
| `backend/app/schemas/workspace.py` | Schema removed (if exists) |
|
||||
| `docs/mockups/resolutionflow-workspaces-mockup.html` | Outdated mockup |
|
||||
|
||||
### Move (2 files)
|
||||
|
||||
| From | To |
|
||||
|------|----|
|
||||
| `frontend/src/components/workspace/CategoryList.tsx` | `frontend/src/components/sidebar/CategoryList.tsx` |
|
||||
| `frontend/src/components/workspace/TagCloud.tsx` | `frontend/src/components/sidebar/TagCloud.tsx` |
|
||||
|
||||
Then delete the empty `frontend/src/components/workspace/` directory.
|
||||
|
||||
### Create (6+ files)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/alembic/versions/0XX_remove_workspace_system.py` | Drop workspace tables/columns |
|
||||
| `backend/alembic/versions/0XX_add_user_pinned_trees.py` | New pinned flows table |
|
||||
| `backend/app/models/user_pinned_tree.py` | UserPinnedTree SQLAlchemy model |
|
||||
| `frontend/src/constants/flowLabels.ts` | Flow type label constants |
|
||||
| `frontend/src/api/pinnedFlows.ts` | Pin/unpin API client |
|
||||
| `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | Pinned flows sidebar component |
|
||||
|
||||
### Modify (14 files — key changes only)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `backend/app/api/router.py` | Remove workspace routes, add pin routes |
|
||||
| `backend/app/models/__init__.py` | Remove Workspace import (line 23, 55), add UserPinnedTree |
|
||||
| `backend/app/models/account.py` | Remove `workspaces` relationship (line 18, 49) |
|
||||
| `backend/app/models/category.py` | Remove `workspace_id` column (line 40) if present |
|
||||
| `backend/app/models/tree.py` | Remove `workspace_id` column/relationship if present |
|
||||
| `backend/app/api/endpoints/trees.py` | Add pin/unpin/list-pinned/reorder endpoints |
|
||||
| `backend/app/schemas/tree.py` | Add PinnedFlow schema, add `is_pinned` to tree list |
|
||||
| `frontend/src/store/userPreferencesStore.ts` | Absorb `sidebarCollapsed` + `toggleSidebar` |
|
||||
| `frontend/src/components/layout/AppLayout.tsx` | Replace workspaceStore with userPreferencesStore |
|
||||
| `frontend/src/components/layout/TopBar.tsx` | Replace workspaceStore, static search labels |
|
||||
| `frontend/src/components/layout/Sidebar.tsx` | Remove workspace switcher, add pinned section + nav sub-items |
|
||||
| `frontend/src/components/layout/NavItem.tsx` | Add `children` sub-item support |
|
||||
| `frontend/src/pages/TreeLibraryPage.tsx` | Remove FolderSidebar, update labels, add pin star |
|
||||
| `frontend/src/components/layout/QuickLaunch.tsx` | Update action labels |
|
||||
| `frontend/src/components/layout/CommandPalette.tsx` | Update search labels |
|
||||
| `frontend/src/pages/QuickStartPage.tsx` | Update workspace/procedure labels (line 150) |
|
||||
| `frontend/src/types/index.ts` | Remove Workspace export (line 11, 14), add PinnedFlow |
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Automated
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
alembic upgrade head # Migration applies clean
|
||||
pytest --override-ini="addopts=" # All tests pass
|
||||
grep -r "workspace" app/ --include="*.py" -l # No active workspace refs
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run build # Compiles clean, zero errors
|
||||
grep -r "workspaceStore\|WorkspaceSwitcher\|workspacesApi\|workspaceLabels" src/ -l
|
||||
# Returns nothing
|
||||
```
|
||||
|
||||
### Manual UI Checks
|
||||
|
||||
- [ ] Sidebar shows "All Flows" with "Troubleshooting" and "Projects" sub-items — no workspace switcher
|
||||
- [ ] Clicking "Troubleshooting" filters library to `?type=troubleshooting`
|
||||
- [ ] Clicking "Projects" filters library to `?type=procedural`
|
||||
- [ ] Clicking "All Flows" removes type filter
|
||||
- [ ] Sub-item counts reflect actual flow counts per type
|
||||
- [ ] Pin a flow from the library card three-dot menu → appears in sidebar "PINNED" section
|
||||
- [ ] Unpin a flow → disappears from sidebar
|
||||
- [ ] Pinned flow click navigates to that flow
|
||||
- [ ] Library page is full-width (no folder sidebar panel)
|
||||
- [ ] Search bar is centered in top bar
|
||||
- [ ] Keyboard shortcut shows "Ctrl+K" on Windows, "⌘K" on Mac
|
||||
- [ ] All user-visible labels say "Flow" / "Project", never "Tree" / "Procedure"
|
||||
- [ ] Empty states use correct terminology
|
||||
- [ ] Toast messages use correct terminology
|
||||
- [ ] Command palette search works with new labels
|
||||
- [ ] Quick Launch actions show correct labels
|
||||
102
backend/alembic/versions/036_add_workspaces.py
Normal file
102
backend/alembic/versions/036_add_workspaces.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Add workspaces table, workspace_id to trees and categories, color to categories
|
||||
|
||||
Revision ID: 036
|
||||
Revises: 035
|
||||
Create Date: 2026-02-15
|
||||
|
||||
Adds workspace system for organizational context above folders.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '036'
|
||||
down_revision: Union[str, None] = '035'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create workspaces table
|
||||
op.create_table(
|
||||
'workspaces',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('slug', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('icon', sa.String(10), nullable=True),
|
||||
sa.Column('accent_color', sa.String(7), nullable=True),
|
||||
sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
|
||||
)
|
||||
op.create_index('ix_workspaces_slug', 'workspaces', ['slug'])
|
||||
op.create_index('ix_workspaces_account_id', 'workspaces', ['account_id'])
|
||||
|
||||
# Add workspace_id to trees
|
||||
op.add_column('trees', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_trees_workspace_id', 'trees', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('ix_trees_workspace_id', 'trees', ['workspace_id'])
|
||||
|
||||
# Add color and workspace_id to tree_categories
|
||||
op.add_column('tree_categories', sa.Column('color', sa.String(7), nullable=True, server_default='#3b82f6'))
|
||||
op.add_column('tree_categories', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_tree_categories_workspace_id', 'tree_categories', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('ix_tree_categories_workspace_id', 'tree_categories', ['workspace_id'])
|
||||
|
||||
# Seed default workspaces for each existing account and assign trees
|
||||
op.execute("""
|
||||
-- Create default workspaces for each account
|
||||
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
|
||||
SELECT 'Troubleshooting', 'troubleshooting', 'Break/fix decision trees', '🔧', '#ef4444', a.id, true, 0
|
||||
FROM accounts a;
|
||||
|
||||
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
|
||||
SELECT 'Procedures', 'procedures', 'Step-by-step operational flows', '📋', '#3b82f6', a.id, false, 1
|
||||
FROM accounts a;
|
||||
|
||||
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
|
||||
SELECT 'Policies', 'policies', 'Compliance & policy builders', '📜', '#8b5cf6', a.id, false, 2
|
||||
FROM accounts a;
|
||||
|
||||
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
|
||||
SELECT 'Finance', 'finance', 'Billing & procurement flows', '💰', '#22c55e', a.id, false, 3
|
||||
FROM accounts a;
|
||||
|
||||
-- Assign existing trees to appropriate workspace based on tree_type
|
||||
UPDATE trees t
|
||||
SET workspace_id = w.id
|
||||
FROM workspaces w
|
||||
WHERE w.account_id = t.account_id
|
||||
AND w.slug = 'troubleshooting'
|
||||
AND t.tree_type = 'troubleshooting';
|
||||
|
||||
UPDATE trees t
|
||||
SET workspace_id = w.id
|
||||
FROM workspaces w
|
||||
WHERE w.account_id = t.account_id
|
||||
AND w.slug = 'procedures'
|
||||
AND t.tree_type = 'procedural';
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_tree_categories_workspace_id', 'tree_categories')
|
||||
op.drop_constraint('fk_tree_categories_workspace_id', 'tree_categories', type_='foreignkey')
|
||||
op.drop_column('tree_categories', 'workspace_id')
|
||||
op.drop_column('tree_categories', 'color')
|
||||
|
||||
op.drop_index('ix_trees_workspace_id', 'trees')
|
||||
op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey')
|
||||
op.drop_column('trees', 'workspace_id')
|
||||
|
||||
op.drop_index('ix_workspaces_account_id', 'workspaces')
|
||||
op.drop_index('ix_workspaces_slug', 'workspaces')
|
||||
op.drop_table('workspaces')
|
||||
40
backend/alembic/versions/037_add_user_pinned_trees.py
Normal file
40
backend/alembic/versions/037_add_user_pinned_trees.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Add user_pinned_trees table
|
||||
|
||||
Revision ID: 037
|
||||
Revises: 036
|
||||
Create Date: 2026-02-15
|
||||
|
||||
Adds pinned flows feature for sidebar favorites.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '037'
|
||||
down_revision: Union[str, None] = '036'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'user_pinned_trees',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('tree_id', UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('pinned_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
|
||||
)
|
||||
op.create_index('idx_user_pinned_trees_user', 'user_pinned_trees', ['user_id'])
|
||||
op.create_index('idx_user_pinned_trees_tree', 'user_pinned_trees', ['tree_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_user_pinned_trees_tree', 'user_pinned_trees')
|
||||
op.drop_index('idx_user_pinned_trees_user', 'user_pinned_trees')
|
||||
op.drop_table('user_pinned_trees')
|
||||
69
backend/alembic/versions/038_remove_workspace_system.py
Normal file
69
backend/alembic/versions/038_remove_workspace_system.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Remove workspace system
|
||||
|
||||
Revision ID: 038
|
||||
Revises: 037
|
||||
Create Date: 2026-02-15
|
||||
|
||||
Drops workspace tables and columns. Keeps tree_categories.color.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '038'
|
||||
down_revision: Union[str, None] = '037'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Drop workspace_id FK and column from trees
|
||||
op.drop_index('ix_trees_workspace_id', 'trees')
|
||||
op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey')
|
||||
op.drop_column('trees', 'workspace_id')
|
||||
|
||||
# 2. Drop workspace_id FK and column from tree_categories
|
||||
op.drop_index('ix_tree_categories_workspace_id', 'tree_categories')
|
||||
op.drop_constraint('fk_tree_categories_workspace_id', 'tree_categories', type_='foreignkey')
|
||||
op.drop_column('tree_categories', 'workspace_id')
|
||||
|
||||
# 3. Drop workspaces table
|
||||
op.drop_index('ix_workspaces_account_id', 'workspaces')
|
||||
op.drop_index('ix_workspaces_slug', 'workspaces')
|
||||
op.drop_table('workspaces')
|
||||
|
||||
# DO NOT drop tree_categories.color — we still use it
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate workspaces table
|
||||
op.create_table(
|
||||
'workspaces',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('slug', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('icon', sa.String(10), nullable=True),
|
||||
sa.Column('accent_color', sa.String(7), nullable=True),
|
||||
sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
|
||||
)
|
||||
op.create_index('ix_workspaces_slug', 'workspaces', ['slug'])
|
||||
op.create_index('ix_workspaces_account_id', 'workspaces', ['account_id'])
|
||||
|
||||
# Re-add workspace_id columns
|
||||
op.add_column('trees', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_trees_workspace_id', 'trees', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('ix_trees_workspace_id', 'trees', ['workspace_id'])
|
||||
|
||||
op.add_column('tree_categories', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key('fk_tree_categories_workspace_id', 'tree_categories', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index('ix_tree_categories_workspace_id', 'tree_categories', ['workspace_id'])
|
||||
@@ -17,8 +17,10 @@ from app.models.folder import UserFolder, user_folder_trees
|
||||
from app.schemas.tree import (
|
||||
TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo,
|
||||
ForkCreate, ForkInfo, TreeShareCreate, TreeShareResponse,
|
||||
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError
|
||||
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError,
|
||||
PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest
|
||||
)
|
||||
from app.models.user_pinned_tree import UserPinnedTree
|
||||
from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin
|
||||
from app.core.permissions import can_edit_tree, can_access_tree
|
||||
from app.core.filters import build_tree_access_filter
|
||||
@@ -1030,3 +1032,185 @@ async def check_tree_can_publish(
|
||||
can_publish=can_publish,
|
||||
errors=[ValidationError(**error) for error in validation_errors]
|
||||
)
|
||||
|
||||
|
||||
# --- Pinned Flows Endpoints ---
|
||||
|
||||
MAX_PINNED_FLOWS = 15
|
||||
|
||||
|
||||
@router.get("/pinned", response_model=PinnedFlowsListResponse)
|
||||
async def list_pinned_flows(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""List user's pinned flows, ordered by display_order."""
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
|
||||
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
|
||||
async def pin_flow(
|
||||
tree_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Pin a flow to the user's sidebar."""
|
||||
# Check tree exists and user can access it
|
||||
tree_result = await db.execute(
|
||||
select(Tree)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(Tree.id == tree_id, Tree.is_active == True)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
|
||||
if not can_access_tree(current_user, tree):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tree")
|
||||
|
||||
# Check if already pinned (idempotent)
|
||||
existing = await db.execute(
|
||||
select(UserPinnedTree).where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == tree_id
|
||||
)
|
||||
)
|
||||
pin = existing.scalar_one_or_none()
|
||||
if pin:
|
||||
return PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
)
|
||||
|
||||
# Check max pins
|
||||
count_result = await db.execute(
|
||||
select(func.count(UserPinnedTree.id)).where(
|
||||
UserPinnedTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
count = count_result.scalar() or 0
|
||||
if count >= MAX_PINNED_FLOWS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Maximum of {MAX_PINNED_FLOWS} pinned flows reached"
|
||||
)
|
||||
|
||||
# Create pin
|
||||
pin = UserPinnedTree(
|
||||
user_id=current_user.id,
|
||||
tree_id=tree_id,
|
||||
display_order=count, # Append at end
|
||||
)
|
||||
db.add(pin)
|
||||
await db.commit()
|
||||
await db.refresh(pin)
|
||||
|
||||
return PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tree_id}/pin")
|
||||
async def unpin_flow(
|
||||
tree_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Unpin a flow from the user's sidebar."""
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree).where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == tree_id
|
||||
)
|
||||
)
|
||||
pin = result.scalar_one_or_none()
|
||||
if pin:
|
||||
await db.delete(pin)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
|
||||
async def reorder_pinned_flows(
|
||||
reorder_data: PinnedFlowReorderRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Update display_order for all pinned flows."""
|
||||
for item in reorder_data.order:
|
||||
await db.execute(
|
||||
update(UserPinnedTree)
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == item.tree_id
|
||||
)
|
||||
.values(display_order=item.display_order)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Return updated list
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
@@ -20,6 +20,7 @@ from .session_share import SessionShare, SessionShareView
|
||||
from .account_limit_override import AccountLimitOverride
|
||||
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||
from .platform_setting import PlatformSetting
|
||||
from .user_pinned_tree import UserPinnedTree
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -51,4 +52,5 @@ __all__ = [
|
||||
"PlanFeatureDefault",
|
||||
"AccountFeatureOverride",
|
||||
"PlatformSetting",
|
||||
"UserPinnedTree",
|
||||
]
|
||||
|
||||
@@ -47,6 +47,10 @@ class TreeCategory(Base):
|
||||
)
|
||||
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
color: Mapped[Optional[str]] = mapped_column(
|
||||
String(7), nullable=True, default='#3b82f6',
|
||||
comment="Hex color for category dot indicator"
|
||||
)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
|
||||
@@ -120,7 +120,6 @@ class Tree(Base):
|
||||
onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Fork tracking
|
||||
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
@@ -184,7 +183,7 @@ class Tree(Base):
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# New organization relationships
|
||||
# Organization relationships
|
||||
category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees")
|
||||
tags: Mapped[list["TreeTag"]] = relationship(
|
||||
"TreeTag",
|
||||
|
||||
37
backend/app/models/user_pinned_tree.py
Normal file
37
backend/app/models/user_pinned_tree.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UserPinnedTree(Base):
|
||||
"""Tracks which trees a user has pinned to their sidebar."""
|
||||
__tablename__ = "user_pinned_trees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
pinned_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -36,6 +36,7 @@ class CategoryResponse(CategoryBase):
|
||||
account_id: Optional[UUID] = None
|
||||
display_order: int
|
||||
is_active: bool
|
||||
color: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tree_count: int = 0 # Computed field
|
||||
@@ -52,6 +53,7 @@ class CategoryListResponse(BaseModel):
|
||||
account_id: Optional[UUID] = None
|
||||
display_order: int
|
||||
is_active: bool
|
||||
color: Optional[str] = None
|
||||
tree_count: int = 0
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -245,3 +245,37 @@ class TreeValidationResponse(BaseModel):
|
||||
"""Response for tree validation endpoint."""
|
||||
can_publish: bool
|
||||
errors: list[ValidationError] = []
|
||||
|
||||
|
||||
# --- Pinned Flows Schemas ---
|
||||
|
||||
class PinnedFlowResponse(BaseModel):
|
||||
"""A pinned flow in the sidebar."""
|
||||
id: UUID
|
||||
tree_id: UUID
|
||||
tree_name: str
|
||||
tree_type: str
|
||||
category_emoji: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
pinned_at: datetime
|
||||
display_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PinnedFlowsListResponse(BaseModel):
|
||||
"""List of pinned flows."""
|
||||
items: list[PinnedFlowResponse]
|
||||
count: int
|
||||
|
||||
|
||||
class PinnedFlowReorderItem(BaseModel):
|
||||
"""Single item in a reorder request."""
|
||||
tree_id: UUID
|
||||
display_order: int
|
||||
|
||||
|
||||
class PinnedFlowReorderRequest(BaseModel):
|
||||
"""Request to reorder pinned flows."""
|
||||
order: list[PinnedFlowReorderItem]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,3 +11,4 @@ export { default as stepCategoriesApi } from './stepCategories'
|
||||
export { default as accountsApi } from './accounts'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
export { default as pinnedFlowsApi } from './pinnedFlows'
|
||||
|
||||
40
frontend/src/api/pinnedFlows.ts
Normal file
40
frontend/src/api/pinnedFlows.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface PinnedFlow {
|
||||
id: string
|
||||
tree_id: string
|
||||
tree_name: string
|
||||
tree_type: 'troubleshooting' | 'procedural'
|
||||
category_emoji?: string
|
||||
category_name?: string
|
||||
pinned_at: string
|
||||
display_order: number
|
||||
}
|
||||
|
||||
export interface PinnedFlowsResponse {
|
||||
items: PinnedFlow[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export const pinnedFlowsApi = {
|
||||
list: async (): Promise<PinnedFlowsResponse> => {
|
||||
const { data } = await apiClient.get('/trees/pinned')
|
||||
return data
|
||||
},
|
||||
|
||||
pin: async (treeId: string): Promise<PinnedFlow> => {
|
||||
const { data } = await apiClient.post(`/trees/${treeId}/pin`)
|
||||
return data
|
||||
},
|
||||
|
||||
unpin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.delete(`/trees/${treeId}/pin`)
|
||||
},
|
||||
|
||||
reorder: async (order: { tree_id: string; display_order: number }[]): Promise<PinnedFlowsResponse> => {
|
||||
const { data } = await apiClient.patch('/trees/pinned/reorder', { order })
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default pinnedFlowsApi
|
||||
@@ -53,8 +53,8 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-white/50 transition-colors',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
@@ -63,8 +63,8 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'fixed z-50 min-w-[160px] rounded-md border border-white/10',
|
||||
'bg-black py-1 shadow-lg animate-scale-in'
|
||||
'fixed z-50 min-w-[160px] rounded-md border border-border',
|
||||
'bg-card py-1 shadow-lg animate-scale-in'
|
||||
)}
|
||||
style={{
|
||||
top: `${menuPosition.top}px`,
|
||||
@@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
item.destructive
|
||||
? 'text-red-400 hover:bg-red-400/10'
|
||||
: 'text-white/70 hover:bg-white/[0.06]'
|
||||
: 'text-muted-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
@@ -34,9 +34,9 @@ export function AdminLayout() {
|
||||
}, [mobileOpen, handleKeyDown])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
<div className="flex h-full">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden w-60 flex-shrink-0 border-r border-white/[0.06] bg-black md:block">
|
||||
<div className="hidden w-60 flex-shrink-0 border-r border-border bg-card md:block">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
@@ -44,14 +44,14 @@ export function AdminLayout() {
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-card/80 backdrop-blur-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 w-60 border-r border-white/[0.06] bg-black shadow-xl">
|
||||
<div className="absolute inset-y-0 left-0 w-60 border-r border-border bg-card shadow-xl">
|
||||
<div className="flex h-12 items-center justify-end px-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="rounded-md p-1.5 text-white/50 hover:bg-white/[0.06]"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -67,7 +67,7 @@ export function AdminLayout() {
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="mb-4 rounded-md p-2 text-white/50 hover:bg-white/[0.06] md:hidden"
|
||||
className="mb-4 rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
return (
|
||||
<aside className={cn('flex h-full flex-col', className)}>
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-bold text-white">Admin Panel</h2>
|
||||
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
@@ -50,8 +50,8 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.path, item.end)
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
@@ -59,13 +59,13 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-white/[0.06] p-3">
|
||||
<div className="border-t border-border p-3">
|
||||
<Link
|
||||
to="/trees"
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
|
||||
'text-white/50 hover:bg-white/[0.06] hover:text-white'
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CategoryRow({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-center gap-3 glass-card rounded-2xl p-4',
|
||||
'flex items-center gap-3 bg-card border border-border rounded-xl p-4',
|
||||
isDragging && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -47,7 +47,7 @@ export function CategoryRow({
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab touch-none text-white/50 hover:text-white active:cursor-grabbing"
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
@@ -56,17 +56,17 @@ export function CategoryRow({
|
||||
{/* Category Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-white">{category.name}</h3>
|
||||
<h3 className="font-medium text-foreground">{category.name}</h3>
|
||||
{!category.is_active && (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70">
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{category.description && (
|
||||
<p className="mt-1 text-sm text-white/40">{category.description}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{category.description}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{stepCount} step{stepCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -77,8 +77,8 @@ export function CategoryRow({
|
||||
type="button"
|
||||
onClick={() => onEdit(category)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 bg-black/50 p-2 text-white/50',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border bg-card p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Edit category"
|
||||
aria-label="Edit category"
|
||||
|
||||
@@ -59,14 +59,14 @@ export function CreateCategoryModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md glass-card rounded-2xl p-6 shadow-lg">
|
||||
<div className="w-full max-w-md bg-card border border-border rounded-xl p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Create Category</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">Create Category</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-white/50 hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -83,7 +83,7 @@ export function CreateCategoryModal({
|
||||
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium text-white">
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Category Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -96,21 +96,21 @@ export function CreateCategoryModal({
|
||||
placeholder="e.g., Network Troubleshooting"
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{name.length}/100 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description Field */}
|
||||
<div>
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-white">
|
||||
Description <span className="text-white/40">(optional)</span>
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
@@ -120,9 +120,9 @@ export function CreateCategoryModal({
|
||||
rows={3}
|
||||
placeholder="Brief description of this category..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
@@ -135,8 +135,8 @@ export function CreateCategoryModal({
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
@@ -145,8 +145,8 @@ export function CreateCategoryModal({
|
||||
type="submit"
|
||||
disabled={isSaving || !name.trim()}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50'
|
||||
'rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Creating...' : 'Create Category'}
|
||||
|
||||
@@ -50,16 +50,16 @@ export function DataTable<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
|
||||
<tr className="border-b border-border bg-accent">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/50',
|
||||
col.sortable && 'cursor-pointer select-none hover:text-white',
|
||||
'px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-muted-foreground',
|
||||
col.sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||
col.className
|
||||
)}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
@@ -87,10 +87,10 @@ export function DataTable<T>({
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: skeletonRows }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-white/[0.06] last:border-0">
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-accent" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@@ -99,7 +99,7 @@ export function DataTable<T>({
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
{emptyState || (
|
||||
<span className="text-white/40">No data found</span>
|
||||
<span className="text-muted-foreground">No data found</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -107,7 +107,7 @@ export function DataTable<T>({
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-white/[0.06] last:border-0 hover:bg-white/[0.04] transition-colors"
|
||||
className="border-b border-border last:border-0 hover:bg-accent transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
||||
|
||||
@@ -68,14 +68,14 @@ export function EditCategoryModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md glass-card rounded-2xl p-6 shadow-lg">
|
||||
<div className="w-full max-w-md bg-card border border-border rounded-xl p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Edit Category</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">Edit Category</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-white/50 hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -92,7 +92,7 @@ export function EditCategoryModal({
|
||||
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="edit-name" className="mb-1 block text-sm font-medium text-white">
|
||||
<label htmlFor="edit-name" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Category Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -105,21 +105,21 @@ export function EditCategoryModal({
|
||||
placeholder="e.g., Network Troubleshooting"
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{name.length}/100 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description Field */}
|
||||
<div>
|
||||
<label htmlFor="edit-description" className="mb-1 block text-sm font-medium text-white">
|
||||
Description <span className="text-white/40">(optional)</span>
|
||||
<label htmlFor="edit-description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
@@ -129,9 +129,9 @@ export function EditCategoryModal({
|
||||
rows={3}
|
||||
placeholder="Brief description of this category..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
@@ -144,8 +144,8 @@ export function EditCategoryModal({
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
@@ -154,8 +154,8 @@ export function EditCategoryModal({
|
||||
type="submit"
|
||||
disabled={isSaving || !name.trim()}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50'
|
||||
'rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
|
||||
@@ -12,10 +12,10 @@ interface EmptyStateProps {
|
||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
{icon && <div className="mb-4 text-white/50">{icon}</div>}
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-white/40">{description}</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
|
||||
@@ -36,20 +36,20 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 pt-4">
|
||||
<span className="text-sm text-white/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {start}-{end} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(btnBase, 'px-2 text-white/50 hover:bg-white/[0.06] hover:text-white')}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
{getPageNumbers().map((p, i) =>
|
||||
p === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1 text-white/40">...</span>
|
||||
<span key={`e${i}`} className="px-1 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
@@ -58,8 +58,8 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
btnBase,
|
||||
'px-2',
|
||||
p === page
|
||||
? 'bg-white text-black'
|
||||
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
|
||||
? 'bg-gradient-brand text-white shadow-lg shadow-primary/20'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
@@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className={cn(btnBase, 'px-2 text-white/50 hover:bg-white/[0.06] hover:text-white')}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -40,21 +40,21 @@ export function SearchInput({ value = '', onSearch, placeholder = 'Search...', c
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/50" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-9 w-full rounded-md border border-white/10 bg-black/50 pl-9 pr-8 text-sm text-white',
|
||||
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
|
||||
'h-9 w-full rounded-md border border-border bg-card pl-9 pr-8 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
{localValue && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-white/50 hover:text-white"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,7 @@ const variantClasses: Record<BadgeVariant, string> = {
|
||||
success: 'bg-emerald-400/10 text-emerald-400',
|
||||
destructive: 'bg-red-400/10 text-red-400',
|
||||
warning: 'bg-yellow-400/10 text-yellow-400',
|
||||
default: 'bg-white/10 text-white/70',
|
||||
default: 'bg-accent text-muted-foreground',
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
|
||||
|
||||
@@ -53,8 +53,8 @@ export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-2 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
aria-label="Actions"
|
||||
>
|
||||
@@ -64,7 +64,7 @@ export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 mt-1 min-w-[180px] glass-card rounded-lg p-1',
|
||||
'absolute z-50 mt-1 min-w-[180px] bg-card border border-border rounded-lg p-1',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
@@ -80,8 +80,8 @@ export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
|
||||
action.disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: action.variant === 'destructive'
|
||||
? 'text-red-400 hover:bg-white/10 hover:text-red-300'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
? 'text-red-400 hover:bg-accent hover:text-red-300'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
|
||||
@@ -34,8 +34,8 @@ export function ConfirmDialog({
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 px-4 py-2 text-sm font-medium',
|
||||
'text-white/60 hover:bg-white/10 hover:text-white',
|
||||
'rounded-xl border border-border px-4 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
@@ -49,7 +49,7 @@ export function ConfirmDialog({
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
confirmVariant === 'destructive'
|
||||
? 'bg-red-400/10 text-red-400 hover:bg-red-400/20 border border-red-400/20'
|
||||
: 'bg-white text-black hover:bg-white/90'
|
||||
: 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Processing...' : confirmLabel}
|
||||
@@ -57,7 +57,7 @@ export function ConfirmDialog({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-white/70">{message}</p>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,19 +37,19 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
<h2 className="mb-2 text-xl font-semibold text-red-400">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="mb-4 text-white/70">
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<pre className="mb-4 overflow-auto rounded-xl bg-white/5 border border-white/[0.06] p-3 text-left text-xs text-red-400">
|
||||
<pre className="mb-4 overflow-auto rounded-xl bg-white/5 border border-border p-3 text-left text-xs text-red-400">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className={cn(
|
||||
'rounded-xl bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
'rounded-xl bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Refresh Page
|
||||
|
||||
@@ -60,23 +60,23 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-col border border-white/[0.06] bg-[#0a0a0a] shadow-lg',
|
||||
'relative flex w-full flex-col border border-border bg-card shadow-lg',
|
||||
'max-h-[100vh] rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl',
|
||||
'animate-scale-in',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
{/* Header - Fixed at top */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-white/[0.06] px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-white">
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-white/40 transition-colors sm:p-1',
|
||||
'hover:bg-white/10 hover:text-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/20'
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors sm:p-1',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
@@ -91,7 +91,7 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
|
||||
|
||||
{/* Footer - Fixed at bottom */}
|
||||
{footer && (
|
||||
<div className="flex-shrink-0 border-t border-white/[0.06] px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex-shrink-0 border-t border-border px-4 py-3 sm:px-6 sm:py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,8 @@ export function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-black">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||
<p className="text-sm text-white/40">Loading...</p>
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ export function PasswordInput({ className, ...props }: PasswordInputProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
title={visible ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
|
||||
@@ -19,17 +19,17 @@ export function RouteError() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-black p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="mb-2 text-4xl font-bold text-white">Oops!</h1>
|
||||
<h1 className="mb-2 text-4xl font-bold text-foreground">Oops!</h1>
|
||||
<h2 className="mb-2 text-xl font-semibold text-red-400">{errorMessage}</h2>
|
||||
{errorDetails && (
|
||||
<p className="mb-4 text-white/70">{errorDetails}</p>
|
||||
<p className="mb-4 text-muted-foreground">{errorDetails}</p>
|
||||
)}
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-xl border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Go Back
|
||||
@@ -37,8 +37,8 @@ export function RouteError() {
|
||||
<button
|
||||
onClick={() => navigate('/trees')}
|
||||
className={cn(
|
||||
'rounded-xl bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
'rounded-xl bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Go Home
|
||||
|
||||
@@ -48,14 +48,14 @@ export function StarRating({
|
||||
sizeClasses[size],
|
||||
star <= value
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-none text-white/30',
|
||||
: 'fill-none text-muted-foreground',
|
||||
!readonly && 'hover:text-yellow-300'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{showCount && (
|
||||
<span className="ml-1 text-sm text-white/40">
|
||||
<span className="ml-1 text-sm text-muted-foreground">
|
||||
({value}/5)
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -37,8 +37,8 @@ export function TagBadges({
|
||||
'rounded-full transition-colors',
|
||||
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
|
||||
variant === 'default'
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/15'
|
||||
: 'bg-white/5 text-white/40 hover:bg-white/10',
|
||||
? 'bg-accent text-muted-foreground hover:bg-accent'
|
||||
: 'bg-accent/50 text-muted-foreground hover:bg-accent',
|
||||
!onTagClick && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
@@ -50,7 +50,7 @@ export function TagBadges({
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
|
||||
'bg-white/5 text-white/40'
|
||||
'bg-accent/50 text-muted-foreground'
|
||||
)}
|
||||
title={tags.slice(maxVisible).join(', ')}
|
||||
>
|
||||
|
||||
@@ -123,10 +123,10 @@ export function TagInput({
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-1.5 rounded-xl border px-2 py-1.5',
|
||||
'bg-black/50 text-white',
|
||||
'focus-within:border-white/30 focus-within:ring-1 focus-within:ring-white/20',
|
||||
'bg-card text-foreground',
|
||||
'focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'border-white/10'
|
||||
'border-border'
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
@@ -136,7 +136,7 @@ export function TagInput({
|
||||
key={tag}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
|
||||
'bg-white/10 text-white/70'
|
||||
'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
@@ -147,7 +147,7 @@ export function TagInput({
|
||||
e.stopPropagation()
|
||||
removeTag(tag)
|
||||
}}
|
||||
className="rounded-full p-0.5 hover:bg-white/20"
|
||||
className="rounded-full p-0.5 hover:bg-accent"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -184,8 +184,8 @@ export function TagInput({
|
||||
placeholder={tags.length === 0 ? placeholder : ''}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 min-w-[80px] border-0 bg-transparent px-1 py-0.5 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'flex-1 min-w-[80px] border-0 bg-transparent px-1 py-0.5 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-0'
|
||||
)}
|
||||
/>
|
||||
@@ -196,8 +196,8 @@ export function TagInput({
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-10 mt-1 w-full rounded-xl border border-white/[0.06]',
|
||||
'bg-[#0a0a0a] shadow-lg'
|
||||
'absolute z-10 mt-1 w-full rounded-xl border border-border',
|
||||
'bg-card shadow-lg'
|
||||
)}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
@@ -206,13 +206,13 @@ export function TagInput({
|
||||
type="button"
|
||||
onClick={() => addTag(suggestion.name)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-3 py-2 text-sm text-white/70',
|
||||
'hover:bg-white/10',
|
||||
index === selectedIndex && 'bg-white/10'
|
||||
'flex w-full items-center justify-between px-3 py-2 text-sm text-muted-foreground',
|
||||
'hover:bg-accent',
|
||||
index === selectedIndex && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<span>{suggestion.name}</span>
|
||||
<span className="text-xs text-white/40">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.usage_count} trees
|
||||
</span>
|
||||
</button>
|
||||
@@ -225,8 +225,8 @@ export function TagInput({
|
||||
type="button"
|
||||
onClick={() => addTag(inputValue)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 border-t border-white/[0.06] px-3 py-2 text-sm',
|
||||
'hover:bg-white/10 text-white'
|
||||
'flex w-full items-center gap-2 border-t border-border px-3 py-2 text-sm',
|
||||
'hover:bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -237,7 +237,7 @@ export function TagInput({
|
||||
)}
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{tags.length}/{maxTags} tags. Press Enter, Tab, comma, or semicolon to add.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
39
frontend/src/components/dashboard/FiltersBar.tsx
Normal file
39
frontend/src/components/dashboard/FiltersBar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Filter } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FilterChip {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface FiltersBarProps {
|
||||
filters: FilterChip[]
|
||||
activeFilter: string
|
||||
onFilterChange: (id: string) => void
|
||||
}
|
||||
|
||||
export function FiltersBar({ filters, activeFilter, onFilterChange }: FiltersBarProps) {
|
||||
return (
|
||||
<div className="fade-in flex items-center gap-1.5 overflow-x-auto py-1" style={{ animationDelay: '100ms' }}>
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => onFilterChange(f.id)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg border px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
activeFilter === f.id
|
||||
? 'border-primary/30 bg-primary/10 text-primary'
|
||||
: 'border-border bg-card text-muted-foreground hover:border-border/80 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-1.5 h-5 w-px shrink-0 bg-border" />
|
||||
<button className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Filter size={14} />
|
||||
More Filters
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/dashboard/QuickStats.tsx
Normal file
44
frontend/src/components/dashboard/QuickStats.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StatCard {
|
||||
label: string
|
||||
value: string | number
|
||||
meta?: string
|
||||
gradient?: boolean
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface QuickStatsProps {
|
||||
stats: StatCard[]
|
||||
}
|
||||
|
||||
export function QuickStats({ stats }: QuickStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stats.map((stat, i) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="fade-in rounded-xl border border-border bg-card p-4 transition-colors hover:border-border/80"
|
||||
style={{ animationDelay: `${50 + i * 30}ms` }}
|
||||
>
|
||||
<p className="font-label text-[0.6875rem] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 font-heading text-2xl font-bold tracking-tight',
|
||||
stat.gradient && 'text-gradient-brand',
|
||||
stat.color
|
||||
)}
|
||||
style={stat.color && !stat.color.startsWith('text-') ? { color: stat.color } : undefined}
|
||||
>
|
||||
{stat.value}
|
||||
</p>
|
||||
{stat.meta && (
|
||||
<p className="mt-0.5 text-[0.6875rem] text-[hsl(var(--text-dimmed))]">{stat.meta}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
frontend/src/components/dashboard/SectionGroup.tsx
Normal file
40
frontend/src/components/dashboard/SectionGroup.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SectionGroupProps {
|
||||
title: string
|
||||
count?: number
|
||||
defaultOpen?: boolean
|
||||
delay?: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SectionGroup({ title, count, defaultOpen = true, delay = 150, children }: SectionGroupProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="fade-in" style={{ animationDelay: `${delay}ms` }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 py-2"
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-gradient-brand" />
|
||||
<span className="font-heading text-[0.8125rem] font-bold uppercase tracking-[0.04em] text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
{count !== undefined && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 font-label text-[0.6875rem] text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn('text-muted-foreground transition-transform', !open && '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="mt-1 space-y-1">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
frontend/src/components/dashboard/SessionsPanel.tsx
Normal file
75
frontend/src/components/dashboard/SessionsPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SessionItem {
|
||||
id: string
|
||||
treeName: string
|
||||
status: 'in_progress' | 'completed' | 'abandoned'
|
||||
currentStep?: string
|
||||
totalSteps?: number
|
||||
stepNumber?: number
|
||||
ticketNumber?: string
|
||||
timeAgo: string
|
||||
}
|
||||
|
||||
interface SessionsPanelProps {
|
||||
sessions: SessionItem[]
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function SessionsPanel({ sessions, delay = 200 }: SessionsPanelProps) {
|
||||
if (sessions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fade-in rounded-xl border border-border bg-card" style={{ animationDelay: `${delay}ms` }}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Recent Sessions</h3>
|
||||
<Link to="/sessions" className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{sessions.map(session => (
|
||||
<Link
|
||||
key={session.id}
|
||||
to={`/sessions/${session.id}`}
|
||||
className="grid items-center gap-3 px-4 py-2.5 transition-colors hover:bg-accent/50"
|
||||
style={{ gridTemplateColumns: '8px 1fr 140px 80px 100px' }}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
session.status === 'completed' ? 'bg-green-500' :
|
||||
session.status === 'in_progress' ? 'bg-amber-500' :
|
||||
'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-sm text-foreground truncate">{session.treeName}</span>
|
||||
|
||||
{/* Progress */}
|
||||
<span className="text-[0.6875rem] text-muted-foreground truncate">
|
||||
{session.status === 'completed'
|
||||
? '✓ Resolved'
|
||||
: session.stepNumber && session.totalSteps
|
||||
? `→ step ${session.stepNumber}/${session.totalSteps}`
|
||||
: '→ In progress'}
|
||||
</span>
|
||||
|
||||
{/* Ticket */}
|
||||
<span className="font-label text-[0.6875rem] text-muted-foreground truncate">
|
||||
{session.ticketNumber || '—'}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-right text-[0.6875rem] text-[hsl(var(--text-dimmed))]">
|
||||
{session.timeAgo}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
frontend/src/components/dashboard/TreeListItem.tsx
Normal file
109
frontend/src/components/dashboard/TreeListItem.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import { getTreeNavigatePath, getTreeEditorPath } from '@/lib/routing'
|
||||
|
||||
interface TreeListItemProps {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
treeType: string
|
||||
category?: { name: string; color?: string } | null
|
||||
tags?: string[]
|
||||
usageCount?: number
|
||||
updatedAt: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export function TreeListItem({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
treeType,
|
||||
category,
|
||||
tags = [],
|
||||
usageCount = 0,
|
||||
updatedAt,
|
||||
icon,
|
||||
}: TreeListItemProps) {
|
||||
const navigate = useNavigate()
|
||||
const categoryColor = category?.color || '#3b82f6'
|
||||
|
||||
const timeAgo = getTimeAgo(updatedAt)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(getTreeNavigatePath(id, treeType))}
|
||||
className="group grid cursor-pointer items-center gap-3 rounded-lg border border-transparent bg-card px-4 py-3 transition-colors hover:border-border hover:bg-[hsl(var(--sidebar-hover))]"
|
||||
style={{ gridTemplateColumns: '40px 1fr 130px 80px 100px 40px' }}
|
||||
>
|
||||
{/* Icon box */}
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-base"
|
||||
style={{ backgroundColor: `${categoryColor}15` }}
|
||||
>
|
||||
{icon || (treeType === 'procedural' ? '📋' : '🔧')}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-sm font-semibold text-foreground truncate">{name}</p>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
{tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="rounded border border-border bg-secondary px-1.5 py-px font-label text-[0.625rem] text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{description && tags.length === 0 && (
|
||||
<span className="text-[0.6875rem] text-muted-foreground truncate">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{category && (
|
||||
<>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: categoryColor }} />
|
||||
<span className="font-label text-xs text-muted-foreground truncate">{category.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage count */}
|
||||
<div className="text-right font-label text-xs text-muted-foreground">
|
||||
{usageCount} uses
|
||||
</div>
|
||||
|
||||
{/* Updated */}
|
||||
<div className="text-right text-[0.6875rem] text-[hsl(var(--text-dimmed))]">
|
||||
{timeAgo}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(getTreeEditorPath(id, treeType))
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
const date = new Date(dateStr).getTime()
|
||||
const diff = now - date
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'Just now'
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'Yesterday'
|
||||
if (days < 7) return `${days}d ago`
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
@@ -1,61 +1,34 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, LogOut, Shield } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { Menu, X, LogOut, User, Shield, ChevronDown, FolderTree, ListOrdered, Layers } from 'lucide-react'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
children?: { path: string; label: string; icon: React.ReactNode }[]
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { effectiveRole, isSuperAdmin } = usePermissions()
|
||||
const { effectiveRole } = usePermissions()
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [flowsDropdownOpen, setFlowsDropdownOpen] = useState(false)
|
||||
const flowsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleLogout = async () => {
|
||||
setMobileMenuOpen(false)
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
// Close mobile menu on route change
|
||||
const [prevPath, setPrevPath] = useState(location.pathname)
|
||||
if (prevPath !== location.pathname) {
|
||||
setPrevPath(location.pathname)
|
||||
if (mobileMenuOpen) setMobileMenuOpen(false)
|
||||
setFlowsDropdownOpen(false)
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setMobileMenuOpen(false)
|
||||
setFlowsDropdownOpen(false)
|
||||
}
|
||||
if (e.key === 'Escape') setMobileMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (flowsDropdownRef.current && !flowsDropdownRef.current.contains(e.target as Node)) {
|
||||
setFlowsDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
if (flowsDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [flowsDropdownOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
@@ -69,250 +42,98 @@ export function AppLayout() {
|
||||
}
|
||||
}, [mobileMenuOpen, handleKeyDown])
|
||||
|
||||
const isFlowsActive = location.pathname.startsWith('/trees') || location.pathname.startsWith('/flows')
|
||||
const handleLogout = async () => {
|
||||
setMobileMenuOpen(false)
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home' },
|
||||
{
|
||||
path: '/trees',
|
||||
label: 'Flows',
|
||||
children: [
|
||||
{ path: '/trees', label: 'All Flows', icon: <Layers className="h-4 w-4 text-white/50" /> },
|
||||
{ path: '/trees?type=troubleshooting', label: 'Troubleshooting', icon: <FolderTree className="h-4 w-4 text-white/50" /> },
|
||||
{ path: '/trees?type=procedural', label: 'Procedures', icon: <ListOrdered className="h-4 w-4 text-white/50" /> },
|
||||
],
|
||||
},
|
||||
{ path: '/my-trees', label: 'My Flows' },
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
{ path: '/shares', label: 'My Shares' },
|
||||
{ path: '/account', label: 'Account' },
|
||||
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
|
||||
const mobileNavItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
|
||||
{ path: '/trees', label: 'All Flows', icon: Box },
|
||||
{ path: '/my-trees', label: 'My Flows', icon: PenLine },
|
||||
{ path: '/sessions', label: 'Sessions', icon: Clock },
|
||||
{ path: '/shares', label: 'Exports', icon: FileText },
|
||||
{ path: '/step-library', label: 'Step Library', icon: Bookmark },
|
||||
{ path: '/account', label: 'Team', icon: Users },
|
||||
{ path: '/account', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
{/* Subtle radial overlay for depth */}
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%),radial-gradient(circle_at_80%_80%,rgba(80,80,100,0.02),transparent_50%)]" />
|
||||
<div className={cn('app-shell', sidebarCollapsed && 'app-shell--collapsed')}>
|
||||
{/* Top Bar - spans full width */}
|
||||
<TopBar />
|
||||
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-white/[0.06] bg-black/80 backdrop-blur-xl">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="rounded-xl p-2 text-white/50 hover:bg-white/10 hover:text-white transition-all sm:hidden"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
{/* Sidebar - desktop only */}
|
||||
<div className="hidden md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
<div className="w-9 h-9 rounded-xl bg-white flex items-center justify-center transition-transform group-hover:scale-105">
|
||||
<BrandLogo size="sm" className="h-5 w-5 invert" />
|
||||
</div>
|
||||
<span className="text-xl font-semibold text-white tracking-tight">
|
||||
ResolutionFlow
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden items-center gap-1 sm:flex">
|
||||
{navItems.map((item) => {
|
||||
if (item.children) {
|
||||
return (
|
||||
<div key={item.path} className="relative" ref={flowsDropdownRef}>
|
||||
<button
|
||||
onClick={() => setFlowsDropdownOpen(!flowsDropdownOpen)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-xl px-4 py-2 text-sm font-medium transition-all',
|
||||
isFlowsActive
|
||||
? 'bg-white/10 text-white border border-white/20'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', flowsDropdownOpen && 'rotate-180')} />
|
||||
</button>
|
||||
{flowsDropdownOpen && (
|
||||
<div className="absolute left-0 z-50 mt-1 w-52 rounded-lg border border-white/10 bg-black/95 p-1 shadow-xl backdrop-blur-sm">
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.path}
|
||||
to={child.path}
|
||||
onClick={() => setFlowsDropdownOpen(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-white/70 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{child.icon}
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'rounded-xl px-4 py-2 text-sm font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-white/10 text-white border border-white/20'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* User info */}
|
||||
<div className="hidden items-center gap-3 sm:flex">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white/[0.06] px-3 py-1.5 border border-white/10">
|
||||
<User className="h-4 w-4 text-white/40" />
|
||||
<span className="text-sm text-white/70">
|
||||
{user?.name || user?.email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Role badge */}
|
||||
{effectiveRole && effectiveRole !== 'engineer' && (
|
||||
<div className="px-3 py-1.5 rounded-xl bg-white/10 border border-white/20">
|
||||
<span className="flex items-center gap-1.5 text-xs text-white font-semibold">
|
||||
<Shield className="h-3 w-3" />
|
||||
{effectiveRole === 'super_admin' ? 'Super Admin' :
|
||||
effectiveRole === 'owner' ? 'Owner' :
|
||||
'Viewer'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'hidden items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium sm:flex',
|
||||
'text-white/50 hover:text-white hover:bg-white/10 transition-all',
|
||||
'border border-white/10 hover:border-white/20'
|
||||
)}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{/* Mobile hamburger - overlaid on topbar */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="fixed left-4 top-3.5 z-50 rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors md:hidden"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Mobile Nav Drawer */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 sm:hidden">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-50 md:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<nav className="absolute inset-y-0 left-0 w-72 border-r border-white/[0.06] bg-black shadow-2xl animate-in slide-in-from-left duration-300">
|
||||
<div className="flex h-16 items-center justify-between border-b border-white/[0.06] px-4">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-white flex items-center justify-center">
|
||||
<BrandLogo size="sm" className="h-5 w-5 invert" />
|
||||
<nav className="absolute inset-y-0 left-0 w-72 border-r border-border bg-[hsl(var(--sidebar-bg))] shadow-2xl animate-slide-in-left">
|
||||
<div className="flex h-14 items-center justify-between border-b border-border px-4">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-brand">
|
||||
<BrandLogo size="sm" className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xl font-semibold text-white tracking-tight">
|
||||
ResolutionFlow
|
||||
</span>
|
||||
<span className="text-sm font-heading font-bold">ResolutionFlow</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="rounded-xl p-2 text-white/50 hover:bg-white/10 hover:text-white transition-all"
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col p-4">
|
||||
<div className="flex flex-col p-3">
|
||||
{/* User info */}
|
||||
<div className="mb-4 border-b border-white/[0.06] pb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<User className="h-4 w-4 text-white/40" />
|
||||
<p className="text-sm font-medium text-white">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-3 border-b border-border pb-3 px-3">
|
||||
<p className="text-sm font-medium text-foreground">{user?.name || user?.email}</p>
|
||||
{effectiveRole && effectiveRole !== 'engineer' && (
|
||||
<div className="inline-flex px-3 py-1.5 rounded-xl bg-white/10 border border-white/20">
|
||||
<span className="flex items-center gap-1.5 text-xs text-white font-semibold">
|
||||
<Shield className="h-3 w-3" />
|
||||
{effectiveRole === 'super_admin' ? 'Super Admin' :
|
||||
effectiveRole === 'owner' ? 'Owner' :
|
||||
'Viewer'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Shield size={10} />
|
||||
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
if (item.children) {
|
||||
return (
|
||||
<div key={item.path}>
|
||||
<div className={cn(
|
||||
'px-4 py-2 text-xs font-semibold uppercase tracking-wider',
|
||||
isFlowsActive ? 'text-white/60' : 'text-white/30'
|
||||
)}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.path}
|
||||
to={child.path}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-all ml-1',
|
||||
'text-white/50 hover:text-white hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
{child.icon}
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{mobileNavItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
key={item.path + item.label}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'block rounded-xl px-4 py-3 text-sm font-medium transition-all',
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-white/10 text-white border border-white/20'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]'
|
||||
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
@@ -320,16 +141,12 @@ export function AppLayout() {
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="mt-4 border-t border-white/[0.06] pt-4">
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium',
|
||||
'text-white/50 hover:text-white hover:bg-white/10 transition-all',
|
||||
'border border-white/10 hover:border-white/20'
|
||||
)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<LogOut size={18} />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
@@ -339,7 +156,7 @@ export function AppLayout() {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="relative animate-in fade-in duration-500">
|
||||
<main className="main-content overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
217
frontend/src/components/layout/CommandPalette.tsx
Normal file
217
frontend/src/components/layout/CommandPalette.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, Loader2, ArrowRight, FileText, Clock } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface ResultItem {
|
||||
id: string
|
||||
type: 'tree' | 'session'
|
||||
title: string
|
||||
subtitle?: string
|
||||
icon: 'tree' | 'session'
|
||||
path: string
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
const navigate = useNavigate()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<ResultItem[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSelectedIndex(0)
|
||||
// Slight delay to ensure modal is rendered
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open, onClose])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (query.length < 2) {
|
||||
setResults([])
|
||||
setIsSearching(false)
|
||||
return
|
||||
}
|
||||
setIsSearching(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const [trees, sessions] = await Promise.all([
|
||||
treesApi.search(query, 6),
|
||||
sessionsApi.list({ size: 5 }).catch(() => [] as Session[]),
|
||||
])
|
||||
|
||||
const treeResults: ResultItem[] = trees.map((t: TreeListItem) => ({
|
||||
id: t.id,
|
||||
type: 'tree' as const,
|
||||
title: t.name,
|
||||
subtitle: t.description || undefined,
|
||||
icon: 'tree' as const,
|
||||
path: getTreeNavigatePath(t.id, t.tree_type),
|
||||
}))
|
||||
|
||||
// Filter sessions by tree name matching query
|
||||
const sessionResults: ResultItem[] = sessions
|
||||
.filter((s: Session) =>
|
||||
s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((s: Session) => ({
|
||||
id: s.id,
|
||||
type: 'session' as const,
|
||||
title: s.tree_snapshot?.name || 'Session',
|
||||
subtitle: s.completed_at ? 'Completed' : 'In progress',
|
||||
icon: 'session' as const,
|
||||
path: `/sessions/${s.id}`,
|
||||
}))
|
||||
|
||||
setResults([...treeResults, ...sessionResults])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, 250)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query])
|
||||
|
||||
const handleSelect = useCallback((item: ResultItem) => {
|
||||
onClose()
|
||||
navigate(item.path)
|
||||
}, [navigate, onClose])
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.min(i + 1, results.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter' && results[selectedIndex]) {
|
||||
e.preventDefault()
|
||||
handleSelect(results[selectedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[20vh]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Palette */}
|
||||
<div className="relative w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl animate-scale-in">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<Search size={18} className="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setSelectedIndex(0) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search flows, sessions…"
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : query.length >= 2 && results.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No results for “{query}”
|
||||
</div>
|
||||
) : results.length > 0 ? (
|
||||
<div className="p-1">
|
||||
{results.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||
i === selectedIndex
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{item.type === 'tree' ? (
|
||||
<FileText size={16} className="shrink-0 opacity-60" />
|
||||
) : (
|
||||
<Clock size={16} className="shrink-0 opacity-60" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{item.title}</p>
|
||||
{item.subtitle && (
|
||||
<p className="text-[0.6875rem] text-muted-foreground truncate">{item.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{i === selectedIndex && (
|
||||
<ArrowRight size={14} className="shrink-0 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Type to search flows and sessions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hints */}
|
||||
{results.length > 0 && (
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-2">
|
||||
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-background px-1 py-px font-label">↑↓</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-background px-1 py-px font-label">↵</kbd>
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
frontend/src/components/layout/NavItem.tsx
Normal file
127
frontend/src/components/layout/NavItem.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavSubItem {
|
||||
href: string
|
||||
label: string
|
||||
count?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
href: string
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
badge?: number | 'dot'
|
||||
matchPaths?: string[]
|
||||
collapsed?: boolean
|
||||
children?: NavSubItem[]
|
||||
}
|
||||
|
||||
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed, children }: NavItemProps) {
|
||||
const location = useLocation()
|
||||
const fullPath = location.pathname + location.search
|
||||
const isActive = matchPaths
|
||||
? matchPaths.some(p => location.pathname.startsWith(p))
|
||||
: href === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(href)
|
||||
|
||||
// Check if any child is specifically active
|
||||
const activeChild = children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
|
||||
const isParentDimmed = !!activeChild && isActive
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
'group relative flex items-center justify-center rounded-lg p-2 transition-all duration-120',
|
||||
isActive
|
||||
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
|
||||
)}
|
||||
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
|
||||
{badge !== undefined && badge !== 0 && badge !== 'dot' && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[0.5rem] font-bold text-primary-foreground">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/nav">
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
|
||||
isActive
|
||||
? isParentDimmed
|
||||
? 'bg-[hsl(var(--sidebar-active))]/50 text-foreground/70'
|
||||
: 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{isActive && !isParentDimmed && (
|
||||
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
|
||||
)}
|
||||
|
||||
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
|
||||
<span className="truncate">{label}</span>
|
||||
|
||||
{/* Badge */}
|
||||
{badge !== undefined && badge !== 0 && (
|
||||
badge === 'dot' ? (
|
||||
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-brand-gradient-from" />
|
||||
) : (
|
||||
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
|
||||
{badge}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Sub-items — visible on hover or when a child is active */}
|
||||
{children && children.length > 0 && (
|
||||
<div className={cn(
|
||||
'mt-0.5 space-y-0.5 overflow-hidden transition-all duration-200',
|
||||
isActive || activeChild
|
||||
? 'max-h-40 opacity-100'
|
||||
: 'max-h-0 opacity-0 group-hover/nav:max-h-40 group-hover/nav:opacity-100'
|
||||
)}>
|
||||
{children.map(child => {
|
||||
const childActive = fullPath === child.href || fullPath.startsWith(child.href + '&')
|
||||
return (
|
||||
<Link
|
||||
key={child.href}
|
||||
to={child.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg pl-9 pr-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
childActive
|
||||
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{child.label}</span>
|
||||
{child.count !== undefined && (
|
||||
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
|
||||
{child.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/layout/NotificationsPanel.tsx
Normal file
105
frontend/src/components/layout/NotificationsPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Bell, CheckCircle, Clock } from 'lucide-react'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { Session } from '@/types/session'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
export function NotificationsPanel() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [hasNew, setHasNew] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
sessionsApi.list({ size: 8 })
|
||||
.then(data => {
|
||||
setSessions(data)
|
||||
// Mark as "new" if any session was updated in the last hour
|
||||
const oneHourAgo = Date.now() - 3600000
|
||||
setHasNew(data.some(s => new Date(s.started_at).getTime() > oneHourAgo))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => { setOpen(!open); setHasNew(false) }}
|
||||
className="relative rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell size={18} />
|
||||
{hasNew && (
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-80 rounded-xl border border-border bg-card shadow-xl animate-scale-in">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-heading font-semibold text-foreground">Activity</h3>
|
||||
<Link
|
||||
to="/sessions"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No recent activity
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto divide-y divide-border">
|
||||
{sessions.map(session => (
|
||||
<Link
|
||||
key={session.id}
|
||||
to={`/sessions/${session.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
{session.completed_at ? (
|
||||
<CheckCircle size={16} className="text-emerald-400" />
|
||||
) : (
|
||||
<Clock size={16} className="text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground truncate">
|
||||
{session.tree_snapshot?.name || 'Session'}
|
||||
</p>
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
{session.completed_at
|
||||
? `Completed ${timeAgo(session.completed_at)}`
|
||||
: `Started ${timeAgo(session.started_at)}`}
|
||||
{session.client_name && ` · ${session.client_name}`}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
157
frontend/src/components/layout/QuickLaunch.tsx
Normal file
157
frontend/src/components/layout/QuickLaunch.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Play, FileText, Bookmark, Users, X } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface QuickLaunchProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
id: string
|
||||
icon: typeof Plus
|
||||
label: string
|
||||
description: string
|
||||
path: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const ACTIONS: QuickAction[] = [
|
||||
{ id: 'new-tree', icon: Plus, label: 'New Troubleshooting Flow', description: 'Create a branching decision tree', path: '/trees/new', color: '#3b82f6' },
|
||||
{ id: 'new-project', icon: Plus, label: 'New Project', description: 'Create a step-by-step project', path: '/flows/new', color: '#8b5cf6' },
|
||||
{ id: 'sessions', icon: Play, label: 'View Sessions', description: 'See active and recent sessions', path: '/sessions', color: '#f59e0b' },
|
||||
{ id: 'step-library', icon: Bookmark, label: 'Step Library', description: 'Browse reusable steps', path: '/step-library', color: '#10b981' },
|
||||
{ id: 'exports', icon: FileText, label: 'Exports & Shares', description: 'View shared session exports', path: '/shares', color: '#6366f1' },
|
||||
{ id: 'team', icon: Users, label: 'Team Settings', description: 'Manage team members and roles', path: '/account', color: '#ec4899' },
|
||||
]
|
||||
|
||||
export function QuickLaunch({ open, onClose }: QuickLaunchProps) {
|
||||
const navigate = useNavigate()
|
||||
const [recentTrees, setRecentTrees] = useState<TreeListItem[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const allItems = [...ACTIONS.map(a => ({ type: 'action' as const, ...a })), ...recentTrees.slice(0, 4).map(t => ({ type: 'tree' as const, ...t }))]
|
||||
const totalItems = allItems.length
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedIndex(0)
|
||||
treesApi.list({ sort_by: 'updated_at' })
|
||||
.then(trees => setRecentTrees(trees.slice(0, 4)))
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, totalItems - 1)) }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)) }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = allItems[selectedIndex]
|
||||
if (!item) return
|
||||
onClose()
|
||||
if (item.type === 'action') navigate(item.path)
|
||||
else navigate(getTreeNavigatePath(item.id, item.tree_type))
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open, selectedIndex, totalItems, allItems, navigate, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose} />
|
||||
<div ref={containerRef} className="relative w-full max-w-md rounded-xl border border-border bg-card shadow-2xl animate-scale-in">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-heading font-semibold text-foreground">Quick Launch</h3>
|
||||
<button onClick={onClose} className="rounded-lg p-1 text-muted-foreground hover:text-foreground">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto p-1">
|
||||
{/* Actions */}
|
||||
<p className="px-3 py-1.5 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">Actions</p>
|
||||
{ACTIONS.map((action, i) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => { onClose(); navigate(action.path) }}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||
i === selectedIndex ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: `${action.color}15` }}
|
||||
>
|
||||
<Icon size={16} style={{ color: action.color }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{action.label}</p>
|
||||
<p className="text-[0.6875rem] text-muted-foreground">{action.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Recent flows */}
|
||||
{recentTrees.length > 0 && (
|
||||
<>
|
||||
<p className="mt-2 px-3 py-1.5 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">Recent Flows</p>
|
||||
{recentTrees.slice(0, 4).map((tree, ti) => {
|
||||
const idx = ACTIONS.length + ti
|
||||
return (
|
||||
<button
|
||||
key={tree.id}
|
||||
onClick={() => { onClose(); navigate(getTreeNavigatePath(tree.id, tree.tree_type)) }}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||
idx === selectedIndex ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-card border border-border text-base">
|
||||
{tree.tree_type === 'procedural' ? '📋' : '🔧'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{tree.name}</p>
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
{tree.tree_type === 'procedural' ? 'Project' : 'Troubleshooting'} · {tree.usage_count} uses
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="ml-auto shrink-0 opacity-40" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-2">
|
||||
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-background px-1 py-px font-label">↑↓</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-background px-1 py-px font-label">↵</kbd>
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/layout/Sidebar.tsx
Normal file
199
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { CategoryList } from '@/components/sidebar/CategoryList'
|
||||
import { TagCloud } from '@/components/sidebar/TagCloud'
|
||||
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
|
||||
import { NavItem } from './NavItem'
|
||||
import { categoriesApi, tagsApi, sessionsApi, treesApi } from '@/api'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
||||
|
||||
const [categories, setCategories] = useState<CategoryItem[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null)
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
||||
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
|
||||
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0 })
|
||||
|
||||
// Fetch sidebar data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [cats, tagList, activeSessions, allTrees, pinnedData] = await Promise.all([
|
||||
categoriesApi.list(),
|
||||
tagsApi.list().catch(() => []),
|
||||
sessionsApi.list({ completed: false, size: 50 }).catch(() => []),
|
||||
treesApi.list({ sort_by: 'name' }).catch(() => []),
|
||||
pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })),
|
||||
])
|
||||
setCategories(cats.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
color: c.color || '#3b82f6',
|
||||
count: c.tree_count || 0,
|
||||
})))
|
||||
setTags(tagList.map((t: { name: string }) => t.name).slice(0, 15))
|
||||
setActiveSessionCount(activeSessions.length)
|
||||
setPinnedFlows(pinnedData.items)
|
||||
|
||||
const total = allTrees.length
|
||||
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
|
||||
const procedural = allTrees.filter(t => t.tree_type === 'procedural').length
|
||||
setTreeCounts({ total, troubleshooting, procedural })
|
||||
} catch {
|
||||
// Silently handle errors
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// Sync active filters from URL when on /trees page
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/trees') {
|
||||
const params = new URLSearchParams(location.search)
|
||||
setActiveCategoryId(params.get('category') || null)
|
||||
const tagsParam = params.get('tags')
|
||||
setActiveTags(tagsParam ? tagsParam.split(',') : [])
|
||||
}
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
const handleCategorySelect = (id: string | null) => {
|
||||
setActiveCategoryId(id)
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (id) {
|
||||
params.set('category', id)
|
||||
} else {
|
||||
params.delete('category')
|
||||
}
|
||||
navigate(`/trees?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
const next = activeTags.includes(tag)
|
||||
? activeTags.filter(t => t !== tag)
|
||||
: [...activeTags, tag]
|
||||
setActiveTags(next)
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (next.length > 0) {
|
||||
params.set('tags', next.join(','))
|
||||
} else {
|
||||
params.delete('tags')
|
||||
}
|
||||
navigate(`/trees?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleUnpin = async (treeId: string) => {
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
setPinnedFlows(prev => prev.filter(f => f.tree_id !== treeId))
|
||||
toast.success('Unpinned from sidebar')
|
||||
} catch {
|
||||
toast.error('Failed to unpin flow')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]">
|
||||
{sidebarCollapsed ? (
|
||||
<>
|
||||
{/* Collapsed: icon-only nav */}
|
||||
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
|
||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" collapsed />
|
||||
<NavItem href="/trees" icon={Box} label="All Flows" matchPaths={['/trees', '/flows']} collapsed />
|
||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Pinned Flows */}
|
||||
<PinnedFlowsSection flows={pinnedFlows} onUnpin={handleUnpin} />
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
{/* Primary Navigation */}
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
|
||||
<NavItem
|
||||
href="/trees"
|
||||
icon={Box}
|
||||
label="All Flows"
|
||||
badge={treeCounts.total || undefined}
|
||||
matchPaths={['/trees', '/flows']}
|
||||
children={[
|
||||
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
|
||||
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
|
||||
]}
|
||||
/>
|
||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
{/* Categories */}
|
||||
<CategoryList
|
||||
categories={categories}
|
||||
activeId={activeCategoryId}
|
||||
onSelect={handleCategorySelect}
|
||||
/>
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
{/* Tags */}
|
||||
<TagCloud tags={tags} activeTags={activeTags} onTagClick={handleTagClick} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Footer */}
|
||||
<div className={cn(
|
||||
"border-t border-[hsl(var(--border-subtle))]",
|
||||
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
|
||||
)}>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<NavItem href="/account" icon={Users} label="Team" />
|
||||
<NavItem href="/account" icon={Settings} label="Settings" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-lg text-[0.8125rem] font-medium text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground transition-colors",
|
||||
sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"
|
||||
)}
|
||||
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={18} />}
|
||||
{!sidebarCollapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
174
frontend/src/components/layout/TopBar.tsx
Normal file
174
frontend/src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Search, Zap, LogOut, User, Shield, Settings } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { QuickLaunch } from './QuickLaunch'
|
||||
import { NotificationsPanel } from './NotificationsPanel'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function TopBar() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { effectiveRole, isSuperAdmin } = usePermissions()
|
||||
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
const [quickLaunchOpen, setQuickLaunchOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleLogout = async () => {
|
||||
setUserMenuOpen(false)
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
}
|
||||
if (userMenuOpen) document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [userMenuOpen])
|
||||
|
||||
// ⌘K / Ctrl+K global shortcut
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setCommandPaletteOpen(prev => !prev)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
}, [handleGlobalKeyDown])
|
||||
|
||||
const initials = user?.name
|
||||
? user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
: user?.email?.[0]?.toUpperCase() || '?'
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="topbar flex items-center gap-4 border-b border-border bg-background px-4">
|
||||
{/* Logo area */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-brand">
|
||||
<BrandLogo size="sm" className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap">
|
||||
<span className="text-foreground">Resolution</span>
|
||||
<span className="text-gradient-brand">Flow</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Spacer - push search to center */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="relative w-full text-left"
|
||||
style={{ maxWidth: '480px' }}
|
||||
>
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="w-full rounded-lg border border-border bg-card py-2 pl-9 pr-14 text-[0.8125rem] text-muted-foreground cursor-pointer hover:border-primary/30 transition-colors">
|
||||
Search flows, sessions, tags…
|
||||
</div>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
|
||||
{navigator.platform?.toLowerCase().includes('mac') ? '⌘K' : 'Ctrl+K'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Spacer - push actions to right */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setQuickLaunchOpen(true)}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
||||
title="Quick Launch"
|
||||
>
|
||||
<Zap size={18} />
|
||||
</button>
|
||||
<NotificationsPanel />
|
||||
|
||||
{/* User avatar & menu */}
|
||||
<div className="relative ml-2" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-brand text-xs font-heading font-bold text-white hover:opacity-90 transition-opacity"
|
||||
title={user?.name || user?.email || 'User'}
|
||||
>
|
||||
{initials}
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-56 rounded-lg border border-border bg-card p-1 shadow-xl animate-scale-in">
|
||||
<div className="border-b border-border px-3 py-2.5 mb-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
||||
{effectiveRole && effectiveRole !== 'engineer' && (
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Shield size={10} />
|
||||
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<User size={14} />
|
||||
Account
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
</Link>
|
||||
{isSuperAdmin && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Shield size={14} />
|
||||
Admin Panel
|
||||
</Link>
|
||||
)}
|
||||
<div className="border-t border-border mt-1 pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette open={commandPaletteOpen} onClose={() => setCommandPaletteOpen(false)} />
|
||||
<QuickLaunch open={quickLaunchOpen} onClose={() => setQuickLaunchOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -89,8 +89,8 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Add to folder"
|
||||
aria-label="Add to folder"
|
||||
@@ -101,14 +101,14 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-full z-20 mt-1 w-48 rounded-md border border-white/10',
|
||||
'bg-black/90 backdrop-blur-sm py-1 shadow-lg'
|
||||
'absolute right-0 top-full z-20 mt-1 w-48 rounded-md border border-border',
|
||||
'bg-card backdrop-blur-sm py-1 shadow-lg'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-white/40">Loading...</div>
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Loading...</div>
|
||||
) : folders.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-white/40">No folders yet</div>
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">No folders yet</div>
|
||||
) : (
|
||||
folders.map((folder) => (
|
||||
<button
|
||||
@@ -117,7 +117,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
||||
e.stopPropagation()
|
||||
toggleFolder(folder.id)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
@@ -125,13 +125,13 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||
{treeFolderIds.has(folder.id) && (
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
<Check className="h-4 w-4 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/10 my-1" />
|
||||
<div className="border-t border-border my-1" />
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -139,7 +139,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
||||
setIsOpen(false)
|
||||
onFolderCreated?.()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create new folder
|
||||
|
||||
@@ -177,12 +177,12 @@ export function FolderEditModal({
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative z-10 w-full max-w-md glass-card rounded-2xl p-6 shadow-lg">
|
||||
<div className="relative z-10 w-full max-w-md bg-card border border-border rounded-2xl p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{isEditMode ? 'Edit Folder' : initialParentId ? 'Create Subfolder' : 'Create Folder'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-white/40 hover:bg-white/[0.06] hover:text-white">
|
||||
<button onClick={onClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent/50 hover:text-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,7 +190,7 @@ export function FolderEditModal({
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Name input */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="folder-name" className="block text-sm font-medium text-white">
|
||||
<label htmlFor="folder-name" className="block text-sm font-medium text-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
@@ -201,9 +201,9 @@ export function FolderEditModal({
|
||||
placeholder="e.g., Citrix Issues"
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'border-white/10'
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'border-border'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -211,7 +211,7 @@ export function FolderEditModal({
|
||||
|
||||
{/* Parent folder dropdown */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="folder-parent" className="block text-sm font-medium text-white">
|
||||
<label htmlFor="folder-parent" className="block text-sm font-medium text-foreground">
|
||||
Parent Folder
|
||||
</label>
|
||||
<select
|
||||
@@ -220,9 +220,9 @@ export function FolderEditModal({
|
||||
onChange={(e) => setParentId(e.target.value || null)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'border-white/10'
|
||||
'bg-card text-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'border-border'
|
||||
)}
|
||||
>
|
||||
<option value="">None (root level)</option>
|
||||
@@ -232,14 +232,14 @@ export function FolderEditModal({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Folders can be nested up to 3 levels deep.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-white">Color</label>
|
||||
<label className="block text-sm font-medium text-foreground">Color</label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{FOLDER_COLORS.map((c) => (
|
||||
<button
|
||||
@@ -262,7 +262,7 @@ export function FolderEditModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn('rounded-md border border-white/10 px-4 py-2 text-sm text-white/60', 'hover:bg-white/10 hover:text-white')}
|
||||
className={cn('rounded-md border border-border px-4 py-2 text-sm text-muted-foreground', 'hover:bg-accent hover:text-foreground')}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -270,8 +270,8 @@ export function FolderEditModal({
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90',
|
||||
'rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -113,8 +113,8 @@ function FolderItem({
|
||||
onClick={() => onFolderSelect(folder.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1 rounded-md py-1.5 text-sm',
|
||||
'transition-colors hover:bg-white/[0.06]',
|
||||
selectedFolderId === folder.id && 'bg-white/10 text-white font-medium'
|
||||
'transition-colors hover:bg-accent',
|
||||
selectedFolderId === folder.id && 'bg-accent text-foreground font-medium'
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + depth * 16}px`, paddingRight: '8px' }}
|
||||
>
|
||||
@@ -125,7 +125,7 @@ function FolderItem({
|
||||
e.stopPropagation()
|
||||
onToggleExpand(folder.id)
|
||||
}}
|
||||
className="shrink-0 p-0.5 hover:bg-white/[0.06] rounded"
|
||||
className="shrink-0 p-0.5 hover:bg-accent rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
@@ -138,7 +138,7 @@ function FolderItem({
|
||||
)}
|
||||
<Folder className="h-4 w-4 shrink-0" style={{ color: folder.color }} />
|
||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||
<span className="text-xs text-white/40 group-hover:hidden">{folder.tree_count}</span>
|
||||
<span className="text-xs text-muted-foreground group-hover:hidden">{folder.tree_count}</span>
|
||||
</button>
|
||||
|
||||
{/* Folder menu button - replaces tree count on hover */}
|
||||
@@ -150,7 +150,7 @@ function FolderItem({
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2 rounded p-1',
|
||||
'hidden group-hover:block',
|
||||
'hover:bg-white/[0.06]'
|
||||
'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
@@ -160,8 +160,8 @@ function FolderItem({
|
||||
{menuOpenId === folder.id && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-full z-10 mt-1 w-40 rounded-md border border-white/10',
|
||||
'bg-black/90 backdrop-blur-sm py-1 shadow-lg'
|
||||
'absolute right-0 top-full z-10 mt-1 w-40 rounded-md border border-border',
|
||||
'bg-card backdrop-blur-sm py-1 shadow-lg'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -170,7 +170,7 @@ function FolderItem({
|
||||
onEditFolder(folder)
|
||||
onMenuToggle(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
@@ -182,7 +182,7 @@ function FolderItem({
|
||||
onAddSubfolder(folder.id)
|
||||
onMenuToggle(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<FolderPlus className="h-3 w-3" />
|
||||
Add Subfolder
|
||||
@@ -362,7 +362,7 @@ export function FolderSidebar({
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
'w-56 shrink-0 border-r border-white/[0.06] bg-transparent',
|
||||
'w-56 shrink-0 border-r border-border bg-transparent',
|
||||
'hidden md:block',
|
||||
mobileOpen && 'fixed inset-y-0 left-0 z-50 block animate-slide-in-left md:relative md:animate-none'
|
||||
)}>
|
||||
@@ -370,10 +370,10 @@ export function FolderSidebar({
|
||||
{/* Mobile close button */}
|
||||
{mobileOpen && (
|
||||
<div className="mb-3 flex items-center justify-between md:hidden">
|
||||
<span className="text-sm font-medium text-white">Folders</span>
|
||||
<span className="text-sm font-medium text-foreground">Folders</span>
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="rounded-md p-1.5 text-white/40 hover:bg-white/[0.06]"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent"
|
||||
aria-label="Close folders"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -382,7 +382,7 @@ export function FolderSidebar({
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full items-center gap-2 text-sm font-medium text-white"
|
||||
className="flex w-full items-center gap-2 text-sm font-medium text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
@@ -399,8 +399,8 @@ export function FolderSidebar({
|
||||
onClick={() => onFolderSelect(null)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
|
||||
'transition-colors hover:bg-white/[0.06]',
|
||||
selectedFolderId === null && 'bg-white/10 text-white font-medium'
|
||||
'transition-colors hover:bg-accent',
|
||||
selectedFolderId === null && 'bg-accent text-foreground font-medium'
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
@@ -409,7 +409,7 @@ export function FolderSidebar({
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading ? (
|
||||
<div className="px-2 py-1.5 text-sm text-white/40">Loading...</div>
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* User folders (hierarchical) */}
|
||||
@@ -439,7 +439,7 @@ export function FolderSidebar({
|
||||
onClick={() => onCreateFolder(null)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
|
||||
'text-white/50 transition-colors hover:bg-white/[0.06] hover:text-white'
|
||||
'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -454,8 +454,8 @@ export function FolderSidebar({
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-50 w-44 rounded-md border border-white/10',
|
||||
'bg-black/90 backdrop-blur-sm py-1 shadow-lg'
|
||||
'fixed z-50 w-44 rounded-md border border-border',
|
||||
'bg-card backdrop-blur-sm py-1 shadow-lg'
|
||||
)}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -465,7 +465,7 @@ export function FolderSidebar({
|
||||
onEditFolder(contextMenu.folder)
|
||||
closeContextMenu()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
@@ -476,7 +476,7 @@ export function FolderSidebar({
|
||||
handleAddSubfolder(contextMenu.folder.id)
|
||||
closeContextMenu()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<FolderPlus className="h-3 w-3" />
|
||||
Add Subfolder
|
||||
|
||||
@@ -119,13 +119,13 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg glass-card rounded-2xl shadow-lg">
|
||||
<div className="relative w-full max-w-lg bg-card border border-border rounded-2xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-white">Share Tree</h2>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Share Tree</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-white/40 hover:bg-white/[0.06] hover:text-white"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -135,9 +135,9 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{/* Tree Info */}
|
||||
<div>
|
||||
<h3 className="font-medium text-white">{tree.name}</h3>
|
||||
<h3 className="font-medium text-foreground">{tree.name}</h3>
|
||||
{tree.description && (
|
||||
<p className="mt-1 text-sm text-white/70 line-clamp-2">
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -145,7 +145,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
|
||||
{/* Visibility Settings */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Visibility
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
@@ -156,19 +156,19 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === level
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
? 'border-border bg-accent text-foreground'
|
||||
: 'border-border bg-transparent text-muted-foreground hover:border-primary/30 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{getVisibilityIcon(level)}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium capitalize">{level}</div>
|
||||
<div className="text-xs text-white/40">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getVisibilityDescription(level)}
|
||||
</div>
|
||||
</div>
|
||||
{visibility === level && (
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
<div className="h-2 w-2 rounded-full bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -178,7 +178,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
{/* Share Link Generation */}
|
||||
{visibility !== 'private' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Share Link
|
||||
</label>
|
||||
|
||||
@@ -189,11 +189,11 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
id="allow-forking"
|
||||
checked={allowForking}
|
||||
onChange={(e) => setAllowForking(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/10 bg-black/50 text-white focus:ring-2 focus:ring-white/20 focus:ring-offset-2 focus:ring-offset-black"
|
||||
className="h-4 w-4 rounded border-border bg-card text-foreground focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 focus:ring-offset-black"
|
||||
/>
|
||||
<label
|
||||
htmlFor="allow-forking"
|
||||
className="text-sm text-white/70 cursor-pointer"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Allow recipients to fork this tree
|
||||
</label>
|
||||
@@ -205,8 +205,8 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
onClick={handleGenerateLink}
|
||||
disabled={isGenerating}
|
||||
className={cn(
|
||||
'w-full rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
'w-full rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate Share Link'}
|
||||
@@ -216,20 +216,20 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
{/* Active Share Link */}
|
||||
{activeShare && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 rounded-md border border-white/10 bg-black/50 p-3">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-card p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={activeShare.share_url}
|
||||
readOnly
|
||||
className="flex-1 bg-transparent text-sm text-white outline-none"
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
copied
|
||||
? 'border-green-500 bg-green-500/10 text-green-400'
|
||||
: 'text-white/60 hover:bg-white/10 hover:text-white'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -245,13 +245,13 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-white/40">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeShare.allow_forking
|
||||
? 'Recipients can fork this tree'
|
||||
: 'Forking disabled for this share'}
|
||||
</p>
|
||||
{shares.length > 1 && (
|
||||
<p className="text-xs text-white/40">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{shares.length} active share links
|
||||
</p>
|
||||
)}
|
||||
@@ -262,12 +262,12 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
<div className="flex justify-end gap-3 border-t border-border px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
|
||||
@@ -21,7 +21,7 @@ const sortOptions: { value: SortBy; label: string }[] = [
|
||||
export function SortDropdown({ value, onChange, className }: SortDropdownProps) {
|
||||
return (
|
||||
<div className={cn('relative inline-flex items-center', className)}>
|
||||
<span className="mr-2 flex items-center gap-1.5 text-sm text-white/40">
|
||||
<span className="mr-2 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Sort:</span>
|
||||
</span>
|
||||
@@ -29,8 +29,8 @@ export function SortDropdown({ value, onChange, className }: SortDropdownProps)
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as SortBy)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 bg-black/50 px-3 py-1.5 text-sm',
|
||||
'text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'rounded-md border border-border bg-card px-3 py-1.5 text-sm',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
|
||||
@@ -30,11 +30,11 @@ export function TreeGridView({
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="glass-card rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-white/20 hover:shadow-md sm:p-6"
|
||||
className="bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-white">{tree.name}</h3>
|
||||
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
||||
{tree.status === 'draft' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-400/10 px-2 py-0.5 text-xs font-medium text-yellow-400">
|
||||
<FileText className="h-3 w-3" />
|
||||
@@ -45,21 +45,21 @@ export function TreeGridView({
|
||||
<div className="flex items-center gap-2">
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-4 w-4 text-white/40" />
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Private tree">
|
||||
<Lock className="h-4 w-4 text-white/40" />
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{tree.category_info && (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-white/70 line-clamp-2">
|
||||
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TreeGridView({
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white/40">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -81,8 +81,8 @@ export function TreeGridView({
|
||||
type="button"
|
||||
onClick={() => onForkTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-2 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Fork tree"
|
||||
aria-label="Fork tree"
|
||||
@@ -94,8 +94,8 @@ export function TreeGridView({
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-2 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
aria-label="Edit tree"
|
||||
@@ -108,7 +108,7 @@ export function TreeGridView({
|
||||
type="button"
|
||||
onClick={() => onDeleteTree(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-red-400/10 hover:text-red-400'
|
||||
)}
|
||||
title="Delete tree"
|
||||
@@ -121,8 +121,8 @@ export function TreeGridView({
|
||||
type="button"
|
||||
onClick={() => onStartSession(tree.id, tree.tree_type)}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Start Session
|
||||
|
||||
@@ -30,12 +30,12 @@ export function TreeListView({
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="flex items-center gap-4 glass-card rounded-2xl p-4 transition-all hover:border-white/20 hover:shadow-sm"
|
||||
className="flex items-center gap-4 bg-card border border-border rounded-2xl p-4 transition-all hover:border-primary/30 hover:shadow-sm"
|
||||
>
|
||||
{/* Left: Name and Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-white truncate">{tree.name}</h3>
|
||||
<h3 className="font-semibold text-foreground truncate">{tree.name}</h3>
|
||||
{tree.status === 'draft' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-400/10 px-2 py-0.5 text-xs font-medium text-yellow-400 flex-shrink-0">
|
||||
<FileText className="h-3 w-3" />
|
||||
@@ -44,15 +44,15 @@ export function TreeListView({
|
||||
)}
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-3.5 w-3.5 text-white/40 flex-shrink-0" />
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Private tree">
|
||||
<Lock className="h-3.5 w-3.5 text-white/40 flex-shrink-0" />
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-white/70 truncate">
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ export function TreeListView({
|
||||
{/* Center: Category and Tags */}
|
||||
<div className="hidden lg:flex items-center gap-2 min-w-0" style={{ maxWidth: '300px' }}>
|
||||
{tree.category_info && (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70 whitespace-nowrap">
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
@@ -73,7 +73,7 @@ export function TreeListView({
|
||||
|
||||
{/* Right: Metadata and Actions */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="hidden sm:flex flex-col items-end text-xs text-white/40">
|
||||
<div className="hidden sm:flex flex-col items-end text-xs text-muted-foreground">
|
||||
<span>v{tree.version}</span>
|
||||
<span>{tree.usage_count} uses</span>
|
||||
</div>
|
||||
@@ -85,8 +85,8 @@ export function TreeListView({
|
||||
type="button"
|
||||
onClick={() => onForkTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Fork tree"
|
||||
aria-label="Fork tree"
|
||||
@@ -99,8 +99,8 @@ export function TreeListView({
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
aria-label="Edit tree"
|
||||
@@ -111,7 +111,7 @@ export function TreeListView({
|
||||
type="button"
|
||||
onClick={() => onDeleteTree(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
title="Delete tree"
|
||||
@@ -125,8 +125,8 @@ export function TreeListView({
|
||||
type="button"
|
||||
onClick={() => onStartSession(tree.id, tree.tree_type)}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 whitespace-nowrap'
|
||||
'rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
Start
|
||||
|
||||
@@ -70,12 +70,12 @@ export function TreeTableView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-white/[0.06]">
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/[0.02] sticky top-0 z-10">
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<thead className="bg-accent/50 sticky top-0 z-10">
|
||||
<tr className="border-b border-border">
|
||||
<th
|
||||
className="px-4 py-3 text-left text-sm font-medium text-white/50 cursor-pointer hover:text-white"
|
||||
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -83,11 +83,11 @@ export function TreeTableView({
|
||||
{getSortIcon('name')}
|
||||
</div>
|
||||
</th>
|
||||
<th className="hidden md:table-cell px-4 py-3 text-left text-sm font-medium text-white/50">
|
||||
<th className="hidden md:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className="hidden lg:table-cell px-4 py-3 text-left text-sm font-medium text-white/50 cursor-pointer hover:text-white"
|
||||
className="hidden lg:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -95,11 +95,11 @@ export function TreeTableView({
|
||||
{getSortIcon('category')}
|
||||
</div>
|
||||
</th>
|
||||
<th className="hidden xl:table-cell px-4 py-3 text-left text-sm font-medium text-white/50">
|
||||
<th className="hidden xl:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Tags
|
||||
</th>
|
||||
<th
|
||||
className="hidden sm:table-cell px-4 py-3 text-center text-sm font-medium text-white/50 cursor-pointer hover:text-white"
|
||||
className="hidden sm:table-cell px-4 py-3 text-center text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('version')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
@@ -108,7 +108,7 @@ export function TreeTableView({
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="hidden sm:table-cell px-4 py-3 text-center text-sm font-medium text-white/50 cursor-pointer hover:text-white"
|
||||
className="hidden sm:table-cell px-4 py-3 text-center text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('usage')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
@@ -117,7 +117,7 @@ export function TreeTableView({
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="hidden md:table-cell px-4 py-3 text-left text-sm font-medium text-white/50 cursor-pointer hover:text-white"
|
||||
className="hidden md:table-cell px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('updated')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -125,17 +125,17 @@ export function TreeTableView({
|
||||
{getSortIcon('updated')}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-white/50">
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-transparent">
|
||||
{trees.map((tree) => (
|
||||
<tr key={tree.id} className="border-b border-white/[0.06] last:border-0 hover:bg-white/[0.04]">
|
||||
<tr key={tree.id} className="border-b border-border last:border-0 hover:bg-accent/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white truncate max-w-[200px]">
|
||||
<span className="font-medium text-foreground truncate max-w-[200px]">
|
||||
{tree.name}
|
||||
</span>
|
||||
{tree.status === 'draft' && (
|
||||
@@ -146,23 +146,23 @@ export function TreeTableView({
|
||||
)}
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-3.5 w-3.5 text-white/40 flex-shrink-0" />
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Private tree">
|
||||
<Lock className="h-3.5 w-3.5 text-white/40 flex-shrink-0" />
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden md:table-cell px-4 py-3 text-sm text-white/70">
|
||||
<td className="hidden md:table-cell px-4 py-3 text-sm text-muted-foreground">
|
||||
<span className="truncate block max-w-[250px]">
|
||||
{tree.description || 'No description'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden lg:table-cell px-4 py-3">
|
||||
{tree.category_info && (
|
||||
<span className="inline-block rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">
|
||||
<span className="inline-block rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
@@ -172,13 +172,13 @@ export function TreeTableView({
|
||||
<TagBadges tags={tree.tags} maxVisible={2} onTagClick={onTagClick} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell px-4 py-3 text-center text-sm text-white/70">
|
||||
<td className="hidden sm:table-cell px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
v{tree.version}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell px-4 py-3 text-center text-sm text-white/70">
|
||||
<td className="hidden sm:table-cell px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
{tree.usage_count}
|
||||
</td>
|
||||
<td className="hidden md:table-cell px-4 py-3 text-sm text-white/70">
|
||||
<td className="hidden md:table-cell px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatDate(tree.updated_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -189,8 +189,8 @@ export function TreeTableView({
|
||||
type="button"
|
||||
onClick={() => onForkTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Fork tree"
|
||||
aria-label="Fork tree"
|
||||
@@ -203,8 +203,8 @@ export function TreeTableView({
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
aria-label="Edit tree"
|
||||
@@ -215,7 +215,7 @@ export function TreeTableView({
|
||||
type="button"
|
||||
onClick={() => onDeleteTree(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
title="Delete tree"
|
||||
@@ -229,8 +229,8 @@ export function TreeTableView({
|
||||
type="button"
|
||||
onClick={() => onStartSession(tree.id, tree.tree_type)}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-3 py-1.5 text-xs font-medium text-black',
|
||||
'hover:bg-white/90 whitespace-nowrap'
|
||||
'rounded-md bg-gradient-brand px-3 py-1.5 text-xs font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
Start
|
||||
|
||||
@@ -11,15 +11,15 @@ interface ViewToggleProps {
|
||||
|
||||
export function ViewToggle({ view, onChange, className }: ViewToggleProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1 rounded-md border border-white/10 p-1', className)}>
|
||||
<div className={cn('flex items-center gap-1 rounded-md border border-border p-1', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('grid')}
|
||||
className={cn(
|
||||
'rounded p-1.5 transition-colors',
|
||||
view === 'grid'
|
||||
? 'bg-white/10 text-white border-white/20'
|
||||
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
|
||||
? 'bg-accent text-foreground border-border'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Grid view"
|
||||
aria-label="Grid view"
|
||||
@@ -32,8 +32,8 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) {
|
||||
className={cn(
|
||||
'rounded p-1.5 transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-white/10 text-white border-white/20'
|
||||
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
|
||||
? 'bg-accent text-foreground border-border'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
@@ -46,8 +46,8 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) {
|
||||
className={cn(
|
||||
'rounded p-1.5 transition-colors',
|
||||
view === 'table'
|
||||
? 'bg-white/10 text-white border-white/20'
|
||||
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
|
||||
? 'bg-accent text-foreground border-border'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Table view"
|
||||
aria-label="Table view"
|
||||
|
||||
@@ -26,49 +26,49 @@ export function IntakeFieldEditor({ field, onUpdate, onRemove }: IntakeFieldEdit
|
||||
const needsOptions = field.field_type === 'select' || field.field_type === 'multi_select'
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-3">
|
||||
<div className="bg-card border border-border rounded-xl p-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/30" />
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={(e) => onUpdate({ label: e.target.value })}
|
||||
placeholder="Field label"
|
||||
className="min-w-0 flex-1 rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="min-w-0 flex-1 rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={field.field_type}
|
||||
onChange={(e) => onUpdate({ field_type: e.target.value as IntakeFieldType })}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
{FIELD_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-1 text-xs text-white/50">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => onUpdate({ required: e.target.checked })}
|
||||
className="rounded border-white/20"
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Req
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="rounded p-1 text-white/40 hover:bg-red-500/20 hover:text-red-400"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-red-500/20 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -76,66 +76,66 @@ export function IntakeFieldEditor({ field, onUpdate, onRemove }: IntakeFieldEdit
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-white/[0.06] pt-3">
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border pt-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-white/50">Variable Name</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Variable Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.variable_name}
|
||||
onChange={(e) => onUpdate({ variable_name: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') })}
|
||||
placeholder="e.g. server_name"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm font-mono text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
<p className="mt-0.5 text-[10px] text-white/30">Used as [VAR:{field.variable_name}]</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">Used as [VAR:{field.variable_name}]</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-white/50">Placeholder</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder || ''}
|
||||
onChange={(e) => onUpdate({ placeholder: e.target.value || undefined })}
|
||||
placeholder="Hint text"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1 block text-xs text-white/50">Help Text</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Help Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.help_text || ''}
|
||||
onChange={(e) => onUpdate({ help_text: e.target.value || undefined })}
|
||||
placeholder="Description or instructions"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-white/50">Default Value</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Default Value</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.default_value || ''}
|
||||
onChange={(e) => onUpdate({ default_value: e.target.value || undefined })}
|
||||
placeholder="Pre-filled value"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-white/50">Group Name</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Group Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.group_name || ''}
|
||||
onChange={(e) => onUpdate({ group_name: e.target.value || undefined })}
|
||||
placeholder="e.g. Network Settings"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsOptions && (
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1 block text-xs text-white/50">Options (one per line)</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Options (one per line)</label>
|
||||
<textarea
|
||||
value={(field.options || []).join('\n')}
|
||||
onChange={(e) => {
|
||||
@@ -144,7 +144,7 @@ export function IntakeFieldEditor({ field, onUpdate, onRemove }: IntakeFieldEdit
|
||||
}}
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
rows={3}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,18 +6,18 @@ export function IntakeFormBuilder() {
|
||||
const { intakeForm, addField, removeField, updateField } = useProceduralEditorStore()
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6">
|
||||
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-white/50" />
|
||||
<h2 className="text-lg font-semibold text-white">Intake Form</h2>
|
||||
<span className="text-sm text-white/40">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Intake Form</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({intakeForm.length} field{intakeForm.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={addField}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Field
|
||||
@@ -25,10 +25,10 @@ export function IntakeFormBuilder() {
|
||||
</div>
|
||||
|
||||
{intakeForm.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-white/10 bg-white/[0.02] py-8 text-center">
|
||||
<FileText className="mx-auto mb-2 h-8 w-8 text-white/20" />
|
||||
<p className="text-sm text-white/40">No intake form fields yet</p>
|
||||
<p className="mt-1 text-xs text-white/30">
|
||||
<div className="rounded-lg border border-dashed border-border bg-white/[0.02] py-8 text-center">
|
||||
<FileText className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No intake form fields yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add fields to collect project data before the procedure starts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const CONTENT_TYPE_OPTIONS: { value: StepContentType; label: string; color: string }[] = [
|
||||
{ value: 'action', label: 'Action', color: 'text-blue-400' },
|
||||
{ value: 'informational', label: 'Info', color: 'text-white/60' },
|
||||
{ value: 'informational', label: 'Info', color: 'text-muted-foreground' },
|
||||
{ value: 'verification', label: 'Verify', color: 'text-emerald-400' },
|
||||
{ value: 'warning', label: 'Warning', color: 'text-yellow-400' },
|
||||
]
|
||||
@@ -24,24 +24,24 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
// Section header steps get a minimal editor
|
||||
if (step.type === 'section_header') {
|
||||
return (
|
||||
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white/50">Edit Section Header</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">Edit Section Header</span>
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Title</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Section title"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,18 +49,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-xs font-medium text-white">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-medium text-foreground">
|
||||
{stepNumber}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white">Edit Step</span>
|
||||
<span className="text-sm font-medium text-foreground">Edit Step</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -69,18 +69,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Title</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Est. Minutes */}
|
||||
<div className="w-40">
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Est. Minutes
|
||||
</label>
|
||||
@@ -90,28 +90,28 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="—"
|
||||
min={1}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Description / Instructions</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Description / Instructions</label>
|
||||
<textarea
|
||||
value={step.description || ''}
|
||||
onChange={(e) => onUpdate({ description: e.target.value })}
|
||||
placeholder="Step instructions. Use [VAR:name] for variables."
|
||||
rows={4}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
{availableVariables.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<span className="text-[10px] text-white/30">Variables:</span>
|
||||
<span className="text-[10px] text-muted-foreground">Variables:</span>
|
||||
{availableVariables.map((v) => (
|
||||
<button
|
||||
key={v.variable_name}
|
||||
onClick={() => onUpdate({ description: (step.description || '') + `[VAR:${v.variable_name}]` })}
|
||||
className="rounded bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-white/50 hover:bg-white/10 hover:text-white/70"
|
||||
className="rounded bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:bg-accent hover:text-muted-foreground"
|
||||
>
|
||||
{v.variable_name}
|
||||
</button>
|
||||
@@ -122,7 +122,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
|
||||
{/* Commands */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<Terminal className="h-3 w-3" />
|
||||
Commands (optional)
|
||||
</label>
|
||||
@@ -131,7 +131,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
onChange={(e) => onUpdate({ commands: e.target.value || undefined })}
|
||||
placeholder="Install-WindowsFeature AD-Domain-Services -IncludeManagementTools"
|
||||
rows={3}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 font-mono text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-muted-foreground"
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
More Options
|
||||
@@ -147,10 +147,10 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
</button>
|
||||
|
||||
{showMore && (
|
||||
<div className="space-y-4 border-t border-white/[0.06] pt-4">
|
||||
<div className="space-y-4 border-t border-border pt-4">
|
||||
{/* Content Type */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Content Type</label>
|
||||
<div className="flex gap-1">
|
||||
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||
step.content_type === opt.value
|
||||
? 'bg-white/15 ' + opt.color
|
||||
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
@@ -181,27 +181,27 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
|
||||
placeholder="Caution: This will restart the service..."
|
||||
rows={2}
|
||||
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
||||
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Expected Outcome (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.expected_outcome || ''}
|
||||
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
|
||||
placeholder="Server should respond with..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
Verification Prompt (optional)
|
||||
</label>
|
||||
@@ -210,15 +210,15 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
value={step.verification_prompt || ''}
|
||||
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
|
||||
placeholder="Confirm the role was installed"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Verification Type</label>
|
||||
<select
|
||||
value={step.verification_type || ''}
|
||||
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="checkbox">Checkbox (confirm done)</option>
|
||||
@@ -230,7 +230,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
{/* Reference URL + Notes toggle */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Reference URL (optional)
|
||||
</label>
|
||||
@@ -239,16 +239,16 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
value={step.reference_url || ''}
|
||||
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
|
||||
placeholder="https://learn.microsoft.com/..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm text-white/60">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.notes_enabled !== false}
|
||||
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
|
||||
className="rounded border-white/20"
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Allow tech notes
|
||||
</label>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: string; label: string }> = {
|
||||
action: { icon: Zap, color: 'text-blue-400', label: 'Action' },
|
||||
informational: { icon: Info, color: 'text-white/50', label: 'Info' },
|
||||
informational: { icon: Info, color: 'text-muted-foreground', label: 'Info' },
|
||||
verification: { icon: CheckCircle2, color: 'text-emerald-400', label: 'Verify' },
|
||||
warning: { icon: AlertTriangle, color: 'text-yellow-400', label: 'Warning' },
|
||||
}
|
||||
@@ -27,26 +27,26 @@ export function StepList() {
|
||||
let stepCounter = 0
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6">
|
||||
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-white/50" />
|
||||
<h2 className="text-lg font-semibold text-white">Steps</h2>
|
||||
<span className="text-sm text-white/40">
|
||||
<Shield className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Steps</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => addSectionHeader()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<SeparatorHorizontal className="h-3.5 w-3.5" />
|
||||
Add Section
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addStep()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Step
|
||||
@@ -60,17 +60,17 @@ export function StepList() {
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-white/[0.02] px-3 py-2"
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={step.title}
|
||||
onChange={(e) => updateStep(step.id, { title: e.target.value })}
|
||||
className="flex-1 bg-transparent text-sm text-white/50 focus:outline-none"
|
||||
className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none"
|
||||
placeholder="Procedure Complete"
|
||||
/>
|
||||
<span className="text-[10px] text-white/30">END</span>
|
||||
<span className="text-[10px] text-muted-foreground">END</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -96,18 +96,18 @@ export function StepList() {
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="group flex items-center gap-2 border-b border-white/[0.06] pb-1 pt-3"
|
||||
className="group flex items-center gap-2 border-b border-border pb-1 pt-3"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/20 group-hover:text-white/40" />
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-white/40 hover:text-white/60"
|
||||
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-muted-foreground"
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
>
|
||||
{step.title || 'Untitled Section'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="shrink-0 rounded p-1 text-white/30 opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -141,13 +141,13 @@ export function StepList() {
|
||||
<div key={step.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors',
|
||||
'hover:border-white/10 hover:bg-white/[0.03]'
|
||||
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
|
||||
'hover:border-primary/30 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/20 group-hover:text-white/40" />
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
|
||||
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white/10 text-xs font-medium text-white/70">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
|
||||
{stepNumber}
|
||||
</span>
|
||||
|
||||
@@ -156,28 +156,28 @@ export function StepList() {
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-white"
|
||||
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
>
|
||||
{step.title || 'Untitled step'}
|
||||
</span>
|
||||
|
||||
{step.estimated_minutes && (
|
||||
<span className="shrink-0 text-[10px] text-white/30">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
~{step.estimated_minutes}m
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
className="shrink-0 rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="shrink-0 rounded p-1 text-white/30 opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -190,7 +190,7 @@ export function StepList() {
|
||||
{/* Add step button at bottom */}
|
||||
<button
|
||||
onClick={() => addStep()}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-white/10 py-2 text-sm text-white/40 transition-colors hover:border-white/20 hover:text-white/60"
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-2 text-sm text-muted-foreground transition-colors hover:border-primary/30 hover:text-muted-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Step
|
||||
|
||||
@@ -58,38 +58,38 @@ export function CompletionSummary({
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-400/10">
|
||||
<CheckCircle2 className="h-8 w-8 text-emerald-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Procedure Complete</h1>
|
||||
<p className="mt-1 text-white/40">{treeName}</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">Procedure Complete</h1>
|
||||
<p className="mt-1 text-muted-foreground">{treeName}</p>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="glass-card rounded-xl p-3 text-center">
|
||||
<div className="bg-card border border-border rounded-xl p-3 text-center">
|
||||
<CheckCircle2 className="mx-auto mb-1 h-5 w-5 text-emerald-400" />
|
||||
<div className="text-lg font-semibold text-white">{procedureSteps.length}</div>
|
||||
<div className="text-xs text-white/40">Steps Completed</div>
|
||||
<div className="text-lg font-semibold text-foreground">{procedureSteps.length}</div>
|
||||
<div className="text-xs text-muted-foreground">Steps Completed</div>
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 text-center">
|
||||
<Clock className="mx-auto mb-1 h-5 w-5 text-white/50" />
|
||||
<div className="text-lg font-semibold text-white">{formatTime(totalMinutes)}</div>
|
||||
<div className="text-xs text-white/40">Total Time</div>
|
||||
<div className="bg-card border border-border rounded-xl p-3 text-center">
|
||||
<Clock className="mx-auto mb-1 h-5 w-5 text-muted-foreground" />
|
||||
<div className="text-lg font-semibold text-foreground">{formatTime(totalMinutes)}</div>
|
||||
<div className="text-xs text-muted-foreground">Total Time</div>
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 text-center">
|
||||
<FileText className="mx-auto mb-1 h-5 w-5 text-white/50" />
|
||||
<div className="text-lg font-semibold text-white">{Object.keys(variables).length}</div>
|
||||
<div className="text-xs text-white/40">Parameters</div>
|
||||
<div className="bg-card border border-border rounded-xl p-3 text-center">
|
||||
<FileText className="mx-auto mb-1 h-5 w-5 text-muted-foreground" />
|
||||
<div className="text-lg font-semibold text-foreground">{Object.keys(variables).length}</div>
|
||||
<div className="text-xs text-muted-foreground">Parameters</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project parameters */}
|
||||
{Object.keys(variables).length > 0 && (
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-white/60">Project Parameters</h3>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">Project Parameters</h3>
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(variables).map(([key, value]) => (
|
||||
<div key={key} className="flex items-baseline justify-between gap-4 text-sm">
|
||||
<span className="font-mono text-white/40">{key}</span>
|
||||
<span className="text-right text-white/70">{value}</span>
|
||||
<span className="font-mono text-muted-foreground">{key}</span>
|
||||
<span className="text-right text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -97,8 +97,8 @@ export function CompletionSummary({
|
||||
)}
|
||||
|
||||
{/* Step details */}
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-white/60">Step Summary</h3>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">Step Summary</h3>
|
||||
<div className="space-y-2">
|
||||
{procedureSteps.map((step, index) => {
|
||||
const completion = completions.get(step.id)
|
||||
@@ -107,15 +107,15 @@ export function CompletionSummary({
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/70">
|
||||
<span className="text-muted-foreground">
|
||||
{index + 1}. {step.title}
|
||||
</span>
|
||||
</div>
|
||||
{completion?.notes && (
|
||||
<p className="mt-0.5 text-xs text-white/30">Note: {completion.notes}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Note: {completion.notes}</p>
|
||||
)}
|
||||
{completion?.verificationValue && (
|
||||
<p className="mt-0.5 text-xs text-white/30">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Verified: {completion.verificationValue}
|
||||
</p>
|
||||
)}
|
||||
@@ -130,14 +130,14 @@ export function CompletionSummary({
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-white/10 px-4 py-2.5 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export Report
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-medium text-black hover:bg-white/90"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-gradient-brand px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
|
||||
@@ -71,10 +71,10 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
const error = errors[field.variable_name]
|
||||
|
||||
const baseInputClass = cn(
|
||||
'w-full rounded-lg border bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:ring-1',
|
||||
'w-full rounded-lg border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1',
|
||||
error
|
||||
? 'border-red-400/50 focus:border-red-400 focus:ring-red-400/20'
|
||||
: 'border-white/10 focus:border-white/30 focus:ring-white/20'
|
||||
: 'border-border focus:border-primary focus:ring-primary/20'
|
||||
)
|
||||
|
||||
let input: React.ReactNode
|
||||
@@ -111,9 +111,9 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
type="checkbox"
|
||||
checked={value === 'true'}
|
||||
onChange={(e) => setValue(field.variable_name, e.target.checked ? 'true' : 'false')}
|
||||
className="rounded border-white/20"
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-white/70">{field.placeholder || field.label}</span>
|
||||
<span className="text-sm text-muted-foreground">{field.placeholder || field.label}</span>
|
||||
</label>
|
||||
)
|
||||
break
|
||||
@@ -161,9 +161,9 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
: [...selected, opt]
|
||||
setValue(field.variable_name, next.join(','))
|
||||
}}
|
||||
className="rounded border-white/20"
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-white/70">{opt}</span>
|
||||
<span className="text-sm text-muted-foreground">{opt}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
@@ -197,12 +197,12 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
|
||||
return (
|
||||
<div key={field.variable_name}>
|
||||
<label className="mb-1 flex items-center gap-1 text-sm font-medium text-white/60">
|
||||
<label className="mb-1 flex items-center gap-1 text-sm font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
{field.help_text && (
|
||||
<p className="mb-1.5 text-xs text-white/30">{field.help_text}</p>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
{input}
|
||||
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
|
||||
@@ -212,12 +212,12 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-lg rounded-2xl border border-white/10 bg-[#0a0a0a] shadow-xl">
|
||||
<div className="mx-4 w-full max-w-lg rounded-2xl border border-border bg-[#0a0a0a] shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="border-b border-white/[0.06] px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-white">Project Information</h2>
|
||||
<p className="mt-0.5 text-sm text-white/40">
|
||||
Fill in the details for <span className="text-white/60">{treeName}</span>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Project Information</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Fill in the details for <span className="text-muted-foreground">{treeName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +227,7 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
{Array.from(groups.entries()).map(([groupName, groupFields]) => (
|
||||
<div key={groupName}>
|
||||
{groupName && (
|
||||
<h3 className="mb-3 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
<h3 className="mb-3 border-b border-border pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{groupName}
|
||||
</h3>
|
||||
)}
|
||||
@@ -239,17 +239,17 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-white/[0.06] px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md border border-white/10 px-4 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
|
||||
className="rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
Start Procedure
|
||||
</button>
|
||||
|
||||
@@ -20,24 +20,24 @@ export function ProgressBar({ currentStep, totalSteps, elapsedMinutes, estimated
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-white/60">
|
||||
<span className="text-muted-foreground">
|
||||
Step {currentStep} of {totalSteps}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{elapsedMinutes !== undefined && (
|
||||
<span className="text-white/50">
|
||||
<span className="text-muted-foreground">
|
||||
{formatTime(elapsed)}
|
||||
{estimatedTotalMinutes ? (
|
||||
<span className="text-white/25"> / est. {formatTime(estimatedTotalMinutes)}</span>
|
||||
<span className="text-muted-foreground"> / est. {formatTime(estimatedTotalMinutes)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-white/70">{percentage}%</span>
|
||||
<span className="font-medium text-muted-foreground">{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-accent">
|
||||
<div
|
||||
className="h-full rounded-full bg-white transition-all duration-300"
|
||||
className="h-full rounded-full bg-gradient-brand transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{showSection && (
|
||||
<div className="mb-1 mt-3 border-b border-white/[0.06] pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/40 first:mt-0">
|
||||
<div className="mb-1 mt-3 border-b border-border pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground first:mt-0">
|
||||
{step.section_header}
|
||||
</div>
|
||||
)}
|
||||
@@ -39,24 +39,24 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
|
||||
onClick={() => onStepClick(index)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors',
|
||||
isCurrent && 'bg-white/10 text-white',
|
||||
!isCurrent && isCompleted && 'text-white/40',
|
||||
!isCurrent && !isCompleted && 'text-white/50 hover:bg-white/[0.04]'
|
||||
isCurrent && 'bg-accent text-foreground',
|
||||
!isCurrent && isCompleted && 'text-muted-foreground',
|
||||
!isCurrent && !isCompleted && 'text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-400" />
|
||||
) : isCurrent ? (
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-white" />
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-foreground" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 shrink-0 text-white/20" />
|
||||
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-medium">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{step.title || 'Untitled step'}</span>
|
||||
{step.estimated_minutes && (
|
||||
<span className="shrink-0 text-[10px] text-white/30">~{step.estimated_minutes}m</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">~{step.estimated_minutes}m</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: string; bg: string; label: string }> = {
|
||||
action: { icon: Zap, color: 'text-blue-400', bg: 'bg-blue-400/10', label: 'Action' },
|
||||
informational: { icon: Info, color: 'text-white/50', bg: 'bg-white/10', label: 'Info' },
|
||||
informational: { icon: Info, color: 'text-muted-foreground', bg: 'bg-accent', label: 'Info' },
|
||||
verification: { icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-400/10', label: 'Verification' },
|
||||
warning: { icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-400/10', label: 'Warning' },
|
||||
}
|
||||
@@ -81,21 +81,21 @@ export function StepDetail({
|
||||
<div className="space-y-4">
|
||||
{/* Step header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-sm font-semibold text-white">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-sm font-semibold text-foreground">
|
||||
{stepNumber}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold text-white">{step.title}</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">{step.title}</h2>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs', config.bg, config.color)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Step {stepNumber} of {totalSteps}
|
||||
</span>
|
||||
{step.estimated_minutes && (
|
||||
<span className="text-xs text-white/30">~{step.estimated_minutes} min</span>
|
||||
<span className="text-xs text-muted-foreground">~{step.estimated_minutes} min</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@ export function StepDetail({
|
||||
|
||||
{/* Description */}
|
||||
{step.description && (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/70">
|
||||
<div className="prose prose-invert prose-sm max-w-none text-muted-foreground">
|
||||
<p className="whitespace-pre-wrap">{resolve(step.description)}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -120,14 +120,14 @@ export function StepDetail({
|
||||
{commandBlocks.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{commandBlocks.map((cmd, i) => (
|
||||
<div key={i} className="rounded-lg border border-white/[0.06] bg-black/50">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
|
||||
<span className="text-xs font-medium text-white/40">
|
||||
<div key={i} className="rounded-lg border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{cmd.label || (cmd.language ? cmd.language : 'Command')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(cmd.code, i)}
|
||||
className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{copiedIndex === i ? <Check className="h-3 w-3 text-emerald-400" /> : <Copy className="h-3 w-3" />}
|
||||
{copiedIndex === i ? 'Copied' : 'Copy'}
|
||||
@@ -143,37 +143,37 @@ export function StepDetail({
|
||||
|
||||
{/* Expected outcome */}
|
||||
{step.expected_outcome && (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-white/50">Expected Outcome</h4>
|
||||
<p className="text-sm text-white/70">{resolve(step.expected_outcome)}</p>
|
||||
<div className="rounded-lg border border-border bg-white/[0.02] p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-muted-foreground">Expected Outcome</h4>
|
||||
<p className="text-sm text-muted-foreground">{resolve(step.expected_outcome)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification */}
|
||||
{verificationPrompt && (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
|
||||
<h4 className="mb-2 text-xs font-medium text-white/50">Verification</h4>
|
||||
<div className="rounded-lg border border-border bg-white/[0.02] p-3">
|
||||
<h4 className="mb-2 text-xs font-medium text-muted-foreground">Verification</h4>
|
||||
{verificationType === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 text-sm text-white/70">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!verificationValue}
|
||||
onChange={(e) => onVerificationChange(e.target.checked ? 'confirmed' : '')}
|
||||
disabled={isCompleted}
|
||||
className="rounded border-white/20"
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{resolve(verificationPrompt)}
|
||||
</label>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-white/70">{resolve(verificationPrompt)}</p>
|
||||
<p className="mb-2 text-sm text-muted-foreground">{resolve(verificationPrompt)}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={verificationValue}
|
||||
onChange={(e) => onVerificationChange(e.target.value)}
|
||||
disabled={isCompleted}
|
||||
placeholder="Enter observed value..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20 disabled:opacity-50"
|
||||
className="w-full rounded border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -183,13 +183,13 @@ export function StepDetail({
|
||||
{/* Notes */}
|
||||
{step.notes_enabled !== false && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Notes</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Add notes for this step..."
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -200,7 +200,7 @@ export function StepDetail({
|
||||
href={resolve(step.reference_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-white/40 hover:text-white"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Reference Documentation
|
||||
@@ -216,7 +216,7 @@ export function StepDetail({
|
||||
'flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
isCompleted
|
||||
? 'bg-emerald-400/10 text-emerald-400'
|
||||
: 'bg-white text-black hover:bg-white/90 disabled:opacity-40 disabled:hover:bg-white'
|
||||
: 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ContinuationModal({
|
||||
{/* Descendant Selection */}
|
||||
{hasDescendants && (
|
||||
<div>
|
||||
<p className="mb-4 text-sm text-white/70">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Select the next step in your troubleshooting path:
|
||||
</p>
|
||||
|
||||
@@ -56,20 +56,20 @@ export function ContinuationModal({
|
||||
onClick={() => onSelectNode(node.id)}
|
||||
title={`From: ${node.parentOptionLabel}`}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-white/[0.06] p-3 text-left transition-colors',
|
||||
'hover:border-white/20 hover:bg-white/10'
|
||||
'flex w-full items-center gap-3 rounded-lg border border-border p-3 text-left transition-colors',
|
||||
'hover:border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-accent">
|
||||
{nodeTypeIcons[node.type]}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-white">{node.label}</p>
|
||||
<p className="text-xs text-white/40">
|
||||
<p className="truncate font-medium text-foreground">{node.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{nodeTypeLabels[node.type]}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 flex-shrink-0 text-white/40" />
|
||||
<ArrowRight className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -79,11 +79,11 @@ export function ContinuationModal({
|
||||
{/* Divider */}
|
||||
{hasDescendants && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-white/40">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Or
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -100,8 +100,8 @@ export function ContinuationModal({
|
||||
<GitBranch className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-white">Build Custom Branch</p>
|
||||
<p className="text-sm text-white/70">
|
||||
<p className="font-medium text-foreground">Build Custom Branch</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your own troubleshooting path with custom steps
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,9 +69,9 @@ export function ExportPreviewModal({
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Export Preview" size="xl">
|
||||
{/* Filename, format info, and controls */}
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-white/70">
|
||||
Filename: <span className="font-mono text-white">{filename}</span>
|
||||
<span className="ml-3 rounded bg-white/10 px-2 py-0.5 text-xs text-white/70">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filename: <span className="font-mono text-foreground">{filename}</span>
|
||||
<span className="ml-3 rounded bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
|
||||
</span>
|
||||
{isModified && (
|
||||
@@ -81,23 +81,23 @@ export function ExportPreviewModal({
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{onToggleSummary && (
|
||||
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSummary}
|
||||
onChange={(e) => onToggleSummary(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/50"
|
||||
className="h-4 w-4 rounded border-border bg-card"
|
||||
/>
|
||||
Include Summary
|
||||
</label>
|
||||
)}
|
||||
{onToggleRedaction && (
|
||||
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={redactionEnabled}
|
||||
onChange={(e) => onToggleRedaction(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/50"
|
||||
className="h-4 w-4 rounded border-border bg-card"
|
||||
/>
|
||||
Mask Sensitive Data
|
||||
</label>
|
||||
@@ -114,13 +114,13 @@ export function ExportPreviewModal({
|
||||
</p>
|
||||
)}
|
||||
{redactionEnabled && redactionSummary && redactionSummary.total === 0 && (
|
||||
<p className="text-xs text-white/40">No sensitive data detected</p>
|
||||
<p className="text-xs text-muted-foreground">No sensitive data detected</p>
|
||||
)}
|
||||
{isModified && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-1 text-xs text-white/40 hover:text-white"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
title="Reset to original"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
@@ -139,9 +139,9 @@ export function ExportPreviewModal({
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
className={cn(
|
||||
'h-96 w-full resize-y rounded-md border border-white/10 bg-black/50 p-4',
|
||||
'font-mono text-sm text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
|
||||
'font-mono text-sm text-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -151,9 +151,9 @@ export function ExportPreviewModal({
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium',
|
||||
'text-white/60 hover:bg-white/10 hover:text-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/20'
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -172,8 +172,8 @@ export function ExportPreviewModal({
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white/20'
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ForkTreeModal({
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
||||
'text-white/60 hover:bg-white/10 hover:text-white',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -59,8 +59,8 @@ export function ForkTreeModal({
|
||||
onClick={handleFork}
|
||||
disabled={isSaving || !name.trim()}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black transition-colors',
|
||||
'hover:bg-white/90',
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium transition-colors',
|
||||
'hover:opacity-90',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -82,13 +82,13 @@ export function ForkTreeModal({
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Save Custom Tree?" footer={footer}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg bg-white/5 p-4">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
|
||||
<GitFork className="h-5 w-5 text-white" />
|
||||
<div className="flex items-start gap-3 rounded-lg bg-accent/50 p-4">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent">
|
||||
<GitFork className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">You've created a custom troubleshooting path!</p>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
<p className="font-medium text-foreground">You've created a custom troubleshooting path!</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Save it as your own personal tree to reuse this troubleshooting flow in the future.
|
||||
</p>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@ export function ForkTreeModal({
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="tree-name" className="mb-1.5 block text-sm font-medium text-white">
|
||||
<label htmlFor="tree-name" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Tree Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -106,15 +106,15 @@ export function ForkTreeModal({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Custom Tree"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20'
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tree-description" className="mb-1.5 block text-sm font-medium text-white">
|
||||
Description <span className="text-white/40">(optional)</span>
|
||||
<label htmlFor="tree-description" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="tree-description"
|
||||
@@ -123,8 +123,8 @@ export function ForkTreeModal({
|
||||
placeholder="Describe what this tree helps troubleshoot..."
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20',
|
||||
'resize-none'
|
||||
)}
|
||||
/>
|
||||
@@ -135,7 +135,7 @@ export function ForkTreeModal({
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-white/40">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The new tree will include your custom steps and will be saved to your personal tree library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -28,8 +28,8 @@ export function PostStepActionModal({
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="What would you like to do?">
|
||||
<div className="space-y-3">
|
||||
<p className="mb-4 text-sm text-white/70">
|
||||
You've created: <strong className="text-white">{step.title}</strong>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
You've created: <strong className="text-foreground">{step.title}</strong>
|
||||
</p>
|
||||
|
||||
{/* Save for Later - Only show if not already from library */}
|
||||
@@ -48,8 +48,8 @@ export function PostStepActionModal({
|
||||
<Bookmark className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">Save for Later</p>
|
||||
<p className="text-sm text-white/70">
|
||||
<p className="font-medium text-foreground">Save for Later</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add to your step library for future use
|
||||
</p>
|
||||
</div>
|
||||
@@ -62,8 +62,8 @@ export function PostStepActionModal({
|
||||
onClick={onUseNow}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-white/[0.06] p-4 text-left transition-colors',
|
||||
'hover:border-white/20 hover:bg-white/10',
|
||||
'w-full rounded-lg border border-border p-4 text-left transition-colors',
|
||||
'hover:border-border hover:bg-accent',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -96,8 +96,8 @@ export function PostStepActionModal({
|
||||
<BookmarkPlus className="h-5 w-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">Do Both</p>
|
||||
<p className="text-sm text-white/70">
|
||||
<p className="font-medium text-foreground">Do Both</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Save to library AND use in this session
|
||||
</p>
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@ export function PostStepActionModal({
|
||||
)}
|
||||
|
||||
{isSaving && (
|
||||
<p className="text-center text-sm text-white/40">Saving...</p>
|
||||
<p className="text-center text-sm text-muted-foreground">Saving...</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -34,21 +34,21 @@ export function SaveSessionAsTreeModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="glass-card w-full max-w-lg rounded-2xl p-6 shadow-lg">
|
||||
<div className="bg-card border border-border w-full max-w-lg rounded-2xl p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Save Session as Tree</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">Save Session as Tree</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-white/40 hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="mb-4 text-sm text-white/70">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Create a new tree from this session's path. The tree will be linked to the original tree as a fork.
|
||||
</p>
|
||||
|
||||
@@ -56,8 +56,8 @@ export function SaveSessionAsTreeModal({
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Tree Name */}
|
||||
<div>
|
||||
<label htmlFor="treeName" className="mb-1 block text-sm font-medium text-white">
|
||||
Tree Name <span className="text-white/40">(optional)</span>
|
||||
<label htmlFor="treeName" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Tree Name <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="treeName"
|
||||
@@ -68,9 +68,9 @@ export function SaveSessionAsTreeModal({
|
||||
disabled={isSaving}
|
||||
maxLength={255}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
@@ -78,8 +78,8 @@ export function SaveSessionAsTreeModal({
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-white">
|
||||
Description <span className="text-white/40">(optional)</span>
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
@@ -89,9 +89,9 @@ export function SaveSessionAsTreeModal({
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
@@ -99,7 +99,7 @@ export function SaveSessionAsTreeModal({
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">Status</label>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
@@ -109,9 +109,9 @@ export function SaveSessionAsTreeModal({
|
||||
checked={status === 'draft'}
|
||||
onChange={() => setStatus('draft')}
|
||||
disabled={isSaving}
|
||||
className="h-4 w-4 border-white/10 text-white focus:ring-2 focus:ring-white/20 focus:ring-offset-0"
|
||||
className="h-4 w-4 border-border text-foreground focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-white">Draft</span>
|
||||
<span className="text-sm text-foreground">Draft</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
@@ -121,9 +121,9 @@ export function SaveSessionAsTreeModal({
|
||||
checked={status === 'published'}
|
||||
onChange={() => setStatus('published')}
|
||||
disabled={isSaving}
|
||||
className="h-4 w-4 border-white/10 text-white focus:ring-2 focus:ring-white/20 focus:ring-offset-0"
|
||||
className="h-4 w-4 border-border text-foreground focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-white">Published</span>
|
||||
<span className="text-sm text-foreground">Published</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,8 +135,8 @@ export function SaveSessionAsTreeModal({
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
@@ -145,8 +145,8 @@ export function SaveSessionAsTreeModal({
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50'
|
||||
'rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save as Tree'}
|
||||
|
||||
@@ -124,8 +124,8 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className={cn(
|
||||
'fixed right-2 top-1/2 z-40 -translate-y-1/2 rounded-md p-2.5',
|
||||
'bg-[#0a0a0a] border border-white/[0.06] shadow-md',
|
||||
'text-white/40 hover:bg-white/10 hover:text-white',
|
||||
'bg-card border border-border shadow-md',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'transition-opacity duration-200',
|
||||
isCollapsed ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
@@ -153,29 +153,29 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
|
||||
'fixed z-40',
|
||||
'inset-0 sm:inset-auto sm:right-2 sm:top-1/2 sm:-translate-y-1/2',
|
||||
'flex w-full flex-col sm:h-[55vh] sm:w-[420px]',
|
||||
'border-white/[0.06] bg-[#0a0a0a]/95 backdrop-blur-md shadow-xl sm:rounded-lg sm:border',
|
||||
'border-border bg-card/95 backdrop-blur-md shadow-xl sm:rounded-lg sm:border',
|
||||
'transition-transform duration-200 ease-out',
|
||||
isCollapsed ? 'translate-x-full' : 'translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-2">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StickyNote className="h-4 w-4 text-white/40" />
|
||||
<span className="text-sm font-medium text-white">Scratchpad</span>
|
||||
<span className="text-xs text-white/30">Ctrl+/</span>
|
||||
<StickyNote className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">Scratchpad</span>
|
||||
<span className="text-xs text-muted-foreground">Ctrl+/</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title={showPreview ? 'Edit' : 'Preview'}
|
||||
>
|
||||
{showPreview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title="Close scratchpad"
|
||||
aria-label="Close scratchpad"
|
||||
>
|
||||
@@ -191,7 +191,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
|
||||
{content.trim() ? (
|
||||
<MarkdownContent content={content} className="text-sm" />
|
||||
) : (
|
||||
<p className="text-sm italic text-white/40">Nothing to preview</p>
|
||||
<p className="text-sm italic text-muted-foreground">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -202,7 +202,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
|
||||
placeholder={"Capture IPs, error codes, server names, user info...\n\nSupports markdown formatting."}
|
||||
className={cn(
|
||||
'h-full min-h-[200px] w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm',
|
||||
'text-white placeholder:text-white/40',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-0'
|
||||
)}
|
||||
/>
|
||||
@@ -210,15 +210,15 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
|
||||
</div>
|
||||
|
||||
{/* Save Indicator */}
|
||||
<div className="border-t border-white/[0.06] px-3 py-1.5">
|
||||
<div className="border-t border-border px-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{saveStatus === 'unsaved' && (
|
||||
<span className="text-white/40">Unsaved changes</span>
|
||||
<span className="text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
{saveStatus === 'saving' && (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-white/40" />
|
||||
<span className="text-white/40">Saving...</span>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Saving...</span>
|
||||
</>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
@@ -228,7 +228,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
|
||||
<span className="text-red-400">Save failed</span>
|
||||
)}
|
||||
{saveStatus === 'idle' && (
|
||||
<span className="text-white/30">Markdown supported</span>
|
||||
<span className="text-muted-foreground">Markdown supported</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,32 +93,32 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
{/* Ticket Number Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ticket number..."
|
||||
value={filters.ticketNumber}
|
||||
onChange={(e) => handleFilterChange('ticketNumber', e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 py-2 pl-9 pr-3',
|
||||
'text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'w-full rounded-md border border-border bg-card py-2 pl-9 pr-3',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Name Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by client name..."
|
||||
value={filters.clientName}
|
||||
onChange={(e) => handleFilterChange('clientName', e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 py-2 pl-9 pr-3',
|
||||
'text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'w-full rounded-md border border-border bg-card py-2 pl-9 pr-3',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -128,8 +128,8 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
value={filters.treeName}
|
||||
onChange={(e) => handleFilterChange('treeName', e.target.value)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 bg-black/50 px-3 py-2',
|
||||
'text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'rounded-md border border-border bg-card px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'sm:min-w-[200px]'
|
||||
)}
|
||||
>
|
||||
@@ -148,19 +148,19 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
<button
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm',
|
||||
'text-white hover:bg-white/10',
|
||||
filters.dateRange?.from && 'border-white/30'
|
||||
'flex w-full items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground hover:bg-accent',
|
||||
filters.dateRange?.from && 'border-primary/30'
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-4 w-4 text-white/40" />
|
||||
<span className={cn(!filters.dateRange?.from && 'text-white/40')}>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className={cn(!filters.dateRange?.from && 'text-muted-foreground')}>
|
||||
{formatDateRange(filters.dateRange)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showDatePicker && (
|
||||
<div className="absolute left-0 top-full z-50 mt-2 rounded-lg border border-white/[0.06] bg-[#0a0a0a] p-4 shadow-lg">
|
||||
<div className="absolute left-0 top-full z-50 mt-2 rounded-lg border border-border bg-[#0a0a0a] p-4 shadow-lg">
|
||||
{/* Date Type Toggle */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
@@ -168,8 +168,8 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
filters.dateType === 'started'
|
||||
? 'bg-white text-black'
|
||||
: 'border border-white/10 text-white/60 hover:bg-white/10 hover:text-white'
|
||||
? 'bg-gradient-brand text-white shadow-lg shadow-primary/20'
|
||||
: 'border border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Started
|
||||
@@ -179,8 +179,8 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
filters.dateType === 'completed'
|
||||
? 'bg-white text-black'
|
||||
: 'border border-white/10 text-white/60 hover:bg-white/10 hover:text-white'
|
||||
? 'bg-gradient-brand text-white shadow-lg shadow-primary/20'
|
||||
: 'border border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Completed
|
||||
@@ -194,8 +194,8 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
key={preset.value}
|
||||
onClick={() => applyDatePreset(preset.value)}
|
||||
className={cn(
|
||||
'rounded-md bg-white/10 px-3 py-1.5 text-sm font-medium text-white/70',
|
||||
'hover:bg-white/20 hover:text-white'
|
||||
'rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent/80 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
@@ -227,8 +227,8 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
setShowDatePicker(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex-1 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
'flex-1 rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Apply
|
||||
@@ -236,8 +236,8 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
<button
|
||||
onClick={() => setShowDatePicker(false)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-3 py-1.5 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
@@ -252,8 +252,8 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
<button
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium',
|
||||
'text-white/60 hover:bg-white/10 hover:text-white'
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
@@ -265,46 +265,46 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
{/* Active Filter Chips */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-white/40">Active filters:</span>
|
||||
<span className="text-sm text-muted-foreground">Active filters:</span>
|
||||
{filters.ticketNumber && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-1 text-sm text-white/70">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-muted-foreground">
|
||||
Ticket: {filters.ticketNumber}
|
||||
<button
|
||||
onClick={() => handleFilterChange('ticketNumber', '')}
|
||||
className="rounded-full p-0.5 hover:bg-white/20"
|
||||
className="rounded-full p-0.5 hover:bg-accent/80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.clientName && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-1 text-sm text-white/70">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-muted-foreground">
|
||||
Client: {filters.clientName}
|
||||
<button
|
||||
onClick={() => handleFilterChange('clientName', '')}
|
||||
className="rounded-full p-0.5 hover:bg-white/20"
|
||||
className="rounded-full p-0.5 hover:bg-accent/80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.treeName && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-1 text-sm text-white/70">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-muted-foreground">
|
||||
Tree: {filters.treeName}
|
||||
<button
|
||||
onClick={() => handleFilterChange('treeName', '')}
|
||||
className="rounded-full p-0.5 hover:bg-white/20"
|
||||
className="rounded-full p-0.5 hover:bg-accent/80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.dateRange?.from && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-1 text-sm text-white/70">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-muted-foreground">
|
||||
{formatDateRange(filters.dateRange)} ({filters.dateType})
|
||||
<button
|
||||
onClick={clearDateRange}
|
||||
className="rounded-full p-0.5 hover:bg-white/20"
|
||||
className="rounded-full p-0.5 hover:bg-accent/80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -51,8 +51,8 @@ export function SessionOutcomeModal({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
@@ -62,8 +62,8 @@ export function SessionOutcomeModal({
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50'
|
||||
'rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? 'Completing...' : 'Complete Session'}
|
||||
@@ -72,7 +72,7 @@ export function SessionOutcomeModal({
|
||||
)}
|
||||
>
|
||||
<form key={String(isOpen)} ref={formRef} className="space-y-4">
|
||||
<p className="text-sm text-white/70">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the session outcome before completion.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
@@ -80,8 +80,8 @@ export function SessionOutcomeModal({
|
||||
<label
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'block cursor-pointer rounded-lg border border-white/10 p-3 transition-colors',
|
||||
'hover:bg-white/[0.04]'
|
||||
'block cursor-pointer rounded-lg border border-border p-3 transition-colors',
|
||||
'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -93,8 +93,8 @@ export function SessionOutcomeModal({
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{option.label}</p>
|
||||
<p className="text-xs text-white/50">{option.description}</p>
|
||||
<p className="text-sm font-medium text-foreground">{option.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -102,31 +102,31 @@ export function SessionOutcomeModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">Outcome Notes (optional)</label>
|
||||
<label className="block text-sm font-medium text-foreground">Outcome Notes (optional)</label>
|
||||
<textarea
|
||||
name="outcome-notes"
|
||||
defaultValue=""
|
||||
rows={3}
|
||||
placeholder="Add context for this outcome..."
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 bg-black/50 px-3 py-2',
|
||||
'text-sm text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'mt-1 block w-full rounded-md border border-border bg-card px-3 py-2',
|
||||
'text-sm text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">Next Steps / Follow-Up (optional)</label>
|
||||
<label className="block text-sm font-medium text-foreground">Next Steps / Follow-Up (optional)</label>
|
||||
<textarea
|
||||
name="next-steps"
|
||||
defaultValue=""
|
||||
rows={3}
|
||||
placeholder="Actions to take after this session..."
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 bg-black/50 px-3 py-2',
|
||||
'text-sm text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'mt-1 block w-full rounded-md border border-border bg-card px-3 py-2',
|
||||
'text-sm text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function SessionTimeline({
|
||||
if (treeType === 'procedural') {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Procedure Steps</h2>
|
||||
<div className="space-y-2">
|
||||
{decisions.map((decision, index) => {
|
||||
const isCompleted = decision.answer === 'completed'
|
||||
@@ -61,31 +61,31 @@ export function SessionTimeline({
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'glass-card rounded-xl p-4',
|
||||
'bg-card border border-border rounded-xl p-4',
|
||||
isCompleted && 'border-l-2 border-emerald-400/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
|
||||
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
|
||||
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{isCompleted ? '\u2713' : index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-white">{decision.question || 'Step'}</p>
|
||||
<p className="font-medium text-foreground">{decision.question || 'Step'}</p>
|
||||
{decision.notes && (
|
||||
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-muted-foreground">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Verification: {decision.command_output}
|
||||
</p>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-1 text-xs text-white/30">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
@@ -94,7 +94,7 @@ export function SessionTimeline({
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
@@ -123,52 +123,52 @@ export function SessionTimeline({
|
||||
// Default: troubleshooting decision timeline
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Decision Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-white" />
|
||||
<span className="text-white/40">
|
||||
<span className="h-3 w-3 rounded-full bg-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Session started: {formatDate(startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{decisions.map((decision, index) => (
|
||||
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
|
||||
<div key={index} className="ml-1 border-l-2 border-border pl-6">
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-muted-foreground" />
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-white">{decision.question}</p>
|
||||
<p className="font-medium text-foreground">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||
<p className="mt-1 text-sm text-foreground">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-muted-foreground">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Command Output</p>
|
||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-muted-foreground whitespace-pre-wrap">
|
||||
{decision.command_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-2 text-xs text-white/50">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -176,7 +176,7 @@ export function SessionTimeline({
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
|
||||
@@ -185,16 +185,16 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg glass-card rounded-2xl shadow-lg">
|
||||
<div className="relative w-full max-w-lg bg-card border border-border rounded-xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Share Session</h2>
|
||||
<p className="text-sm text-white/40">{sessionLabel}</p>
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground">Share Session</h2>
|
||||
<p className="text-sm text-muted-foreground">{sessionLabel}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-white/40 hover:bg-white/[0.06] hover:text-white"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -206,7 +206,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
<div className="space-y-4">
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Visibility
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
@@ -215,17 +215,17 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === 'account'
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
? 'border-primary/30 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-transparent text-muted-foreground hover:border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Account Only</div>
|
||||
<div className="text-xs text-white/40">Visible to your team</div>
|
||||
<div className="text-xs text-muted-foreground">Visible to your team</div>
|
||||
</div>
|
||||
{visibility === 'account' && (
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
@@ -233,17 +233,17 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === 'public'
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
? 'border-primary/30 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-transparent text-muted-foreground hover:border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Public</div>
|
||||
<div className="text-xs text-white/40">Anyone with the link</div>
|
||||
<div className="text-xs text-muted-foreground">Anyone with the link</div>
|
||||
</div>
|
||||
{visibility === 'public' && (
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -254,8 +254,8 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
|
||||
{/* Share Name */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Share Name <span className="text-white/40">(optional)</span>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Share Name <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -263,8 +263,8 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
onChange={(e) => setShareName(e.target.value.slice(0, 100))}
|
||||
placeholder="e.g. Training link, Customer escalation"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder-white/30',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground placeholder-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
maxLength={100}
|
||||
/>
|
||||
@@ -272,7 +272,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
|
||||
{/* Expiration */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Expiration
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -283,8 +283,8 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-1.5 text-sm transition-colors',
|
||||
expirationPreset === preset.value
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/10 text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
? 'border-primary/30 bg-primary/10 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
@@ -297,8 +297,8 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
value={customDatetime}
|
||||
onChange={(e) => setCustomDatetime(e.target.value)}
|
||||
className={cn(
|
||||
'mt-2 w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'mt-2 w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'[color-scheme:dark]'
|
||||
)}
|
||||
/>
|
||||
@@ -310,8 +310,8 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
onClick={handleGenerateLink}
|
||||
disabled={isGenerating || (expirationPreset === 'custom' && !customDatetime)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
'flex w-full items-center justify-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
@@ -322,7 +322,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
{/* Existing Shares */}
|
||||
{shares.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white">
|
||||
<h3 className="mb-3 text-sm font-medium text-foreground">
|
||||
Active Shares ({shares.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
@@ -332,7 +332,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className="glass-card rounded-xl p-4 space-y-2"
|
||||
className="bg-card border border-border rounded-xl p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -340,8 +340,8 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
|
||||
share.visibility === 'public'
|
||||
? 'bg-white/10 text-white/70'
|
||||
: 'bg-white/10 text-white/70'
|
||||
? 'bg-accent text-muted-foreground'
|
||||
: 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{share.visibility === 'public' ? (
|
||||
<Globe className="h-3 w-3" />
|
||||
@@ -350,11 +350,11 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
)}
|
||||
{share.visibility === 'public' ? 'Public' : 'Account'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{share.share_name || 'Untitled share'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{getRelativeTime(share.created_at)}</span>
|
||||
<span>
|
||||
{share.view_count > 0
|
||||
@@ -375,10 +375,10 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
onClick={() => handleCopyUrl(share)}
|
||||
title="Copy share URL"
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-sm transition-colors',
|
||||
'rounded-md border border-border p-1.5 text-sm transition-colors',
|
||||
isCopied
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-white/50 hover:bg-white/10 hover:text-white'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
@@ -390,7 +390,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
<button
|
||||
onClick={() => handleRevoke(share.id)}
|
||||
title="Revoke share"
|
||||
className="rounded-md border border-white/10 p-1.5 text-white/50 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-colors"
|
||||
className="rounded-md border border-border p-1.5 text-muted-foreground hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -406,18 +406,18 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
{/* Loading state */}
|
||||
{isLoadingShares && shares.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
<div className="flex justify-end gap-3 border-t border-border px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
|
||||
@@ -7,11 +7,11 @@ interface SharedSessionTreePreviewProps {
|
||||
}
|
||||
|
||||
const nodeTypeColors: Record<string, string> = {
|
||||
root: 'bg-white',
|
||||
root: 'bg-foreground',
|
||||
decision: 'bg-blue-400',
|
||||
action: 'bg-yellow-400',
|
||||
solution: 'bg-emerald-400',
|
||||
information: 'bg-white/50',
|
||||
information: 'bg-muted-foreground',
|
||||
}
|
||||
|
||||
function getNodeTitle(node: Record<string, unknown>): string {
|
||||
@@ -36,7 +36,7 @@ function TreeNode({
|
||||
const nodeType = (node.node_type as string) || 'decision'
|
||||
const isInPath = pathTaken.includes(nodeId)
|
||||
const children = (node.children as Record<string, unknown>[]) || []
|
||||
const colorClass = nodeTypeColors[nodeType] || 'bg-white/50'
|
||||
const colorClass = nodeTypeColors[nodeType] || 'bg-muted-foreground'
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,8 +44,8 @@ function TreeNode({
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 text-sm',
|
||||
isInPath
|
||||
? 'rounded-md border-l-2 border-white/40 bg-white/10 font-medium text-white'
|
||||
: 'text-white/30'
|
||||
? 'rounded-md border-l-2 border-muted-foreground bg-accent font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
>
|
||||
@@ -75,9 +75,9 @@ export function SharedSessionTreePreview({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl">
|
||||
<div className="sticky top-0 z-10 rounded-t-2xl border-b border-white/[0.06] bg-black/80 px-6 py-4 backdrop-blur">
|
||||
<h3 className="text-sm font-semibold text-white">Tree Structure</h3>
|
||||
<div className="bg-card border border-border rounded-2xl">
|
||||
<div className="sticky top-0 z-10 rounded-t-2xl border-b border-border bg-black/80 px-6 py-4 backdrop-blur">
|
||||
<h3 className="text-sm font-semibold text-foreground">Tree Structure</h3>
|
||||
</div>
|
||||
<div className="max-h-[600px] overflow-y-auto py-2">
|
||||
<TreeNode node={treeStructure as unknown as Record<string, unknown>} depth={0} pathTaken={pathTaken} />
|
||||
|
||||
@@ -78,19 +78,19 @@ export function StepRatingModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="glass-card w-full max-w-2xl max-h-[90vh] flex flex-col rounded-2xl shadow-lg">
|
||||
<div className="bg-card border border-border w-full max-w-2xl max-h-[90vh] flex flex-col rounded-2xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Rate Your Experience</h2>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
<h2 className="text-lg font-semibold text-foreground">Rate Your Experience</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Help others by rating the steps you used ({librarySteps.length} step{librarySteps.length !== 1 ? 's' : ''})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-white/40 hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -102,14 +102,14 @@ export function StepRatingModal({
|
||||
{librarySteps.map((step) => {
|
||||
const rating = getRating(step.id)
|
||||
return (
|
||||
<div key={step.id} className="rounded-lg border border-white/[0.06] bg-[#0a0a0a] p-4">
|
||||
<div key={step.id} className="rounded-lg border border-border bg-[#0a0a0a] p-4">
|
||||
{/* Step Title */}
|
||||
<h3 className="font-medium text-white">{step.title}</h3>
|
||||
<p className="mt-1 text-sm text-white/40 capitalize">{step.step_type}</p>
|
||||
<h3 className="font-medium text-foreground">{step.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground capitalize">{step.step_type}</p>
|
||||
|
||||
{/* Star Rating */}
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-sm font-medium text-white">
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||
Rating
|
||||
</label>
|
||||
<StarRating
|
||||
@@ -121,7 +121,7 @@ export function StepRatingModal({
|
||||
|
||||
{/* Was this helpful? */}
|
||||
<div className="mt-3">
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Was this helpful?
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -133,7 +133,7 @@ export function StepRatingModal({
|
||||
'flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors',
|
||||
rating?.helpful === true
|
||||
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-400'
|
||||
: 'border-white/10 text-white/60 hover:bg-white/10 hover:text-white',
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -148,7 +148,7 @@ export function StepRatingModal({
|
||||
'flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors',
|
||||
rating?.helpful === false
|
||||
? 'border-red-400/20 bg-red-400/10 text-red-400'
|
||||
: 'border-white/10 text-white/60 hover:bg-white/10 hover:text-white',
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -160,8 +160,8 @@ export function StepRatingModal({
|
||||
|
||||
{/* Optional Review */}
|
||||
<div className="mt-3">
|
||||
<label htmlFor={`review-${step.id}`} className="mb-1 block text-sm font-medium text-white">
|
||||
Review <span className="text-white/40">(optional)</span>
|
||||
<label htmlFor={`review-${step.id}`} className="mb-1 block text-sm font-medium text-foreground">
|
||||
Review <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id={`review-${step.id}`}
|
||||
@@ -172,13 +172,13 @@ export function StepRatingModal({
|
||||
rows={2}
|
||||
placeholder="Share your experience with this step..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-white/40 text-right">
|
||||
<p className="mt-1 text-xs text-muted-foreground text-right">
|
||||
{rating?.review?.length || 0}/500
|
||||
</p>
|
||||
</div>
|
||||
@@ -189,14 +189,14 @@ export function StepRatingModal({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-white/[0.06] px-6 py-4">
|
||||
<div className="flex justify-end gap-2 border-t border-border px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Skip
|
||||
@@ -206,8 +206,8 @@ export function StepRatingModal({
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50'
|
||||
'rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Submitting...' : 'Submit Ratings'}
|
||||
|
||||
@@ -22,14 +22,14 @@ export function VariablePromptModal({ prompt, onSubmit, onCancel }: VariableProm
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="glass-card w-full max-w-md rounded-2xl p-6 shadow-lg">
|
||||
<h2 className="mb-1 text-lg font-semibold text-white">Input Required</h2>
|
||||
<p className="mb-4 text-sm text-white/40">
|
||||
<div className="bg-card border border-border w-full max-w-md rounded-2xl p-6 shadow-lg">
|
||||
<h2 className="mb-1 text-lg font-semibold text-foreground">Input Required</h2>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
This step requires you to provide a value.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="mb-2 block text-sm font-medium text-white/70">
|
||||
<label className="mb-2 block text-sm font-medium text-muted-foreground">
|
||||
{prompt}
|
||||
</label>
|
||||
<input
|
||||
@@ -39,8 +39,8 @@ export function VariablePromptModal({ prompt, onSubmit, onCancel }: VariableProm
|
||||
placeholder="Enter value..."
|
||||
autoFocus
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -49,8 +49,8 @@ export function VariablePromptModal({ prompt, onSubmit, onCancel }: VariableProm
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
'flex-1 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Continue
|
||||
@@ -59,8 +59,8 @@ export function VariablePromptModal({ prompt, onSubmit, onCancel }: VariableProm
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={cn(
|
||||
'rounded-lg border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
'rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Skip
|
||||
|
||||
47
frontend/src/components/sidebar/CategoryList.tsx
Normal file
47
frontend/src/components/sidebar/CategoryList.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface CategoryListProps {
|
||||
categories: CategoryItem[]
|
||||
activeId?: string | null
|
||||
onSelect: (id: string | null) => void
|
||||
}
|
||||
|
||||
export function CategoryList({ categories, activeId, onSelect }: CategoryListProps) {
|
||||
if (categories.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-3 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground">
|
||||
Categories
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => onSelect(activeId === cat.id ? null : cat.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-sm transition-colors',
|
||||
activeId === cat.id
|
||||
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: cat.color }}
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">{cat.name}</span>
|
||||
<span className="font-label text-[0.6875rem] text-[hsl(var(--text-dimmed))]">{cat.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
frontend/src/components/sidebar/PinnedFlowsSection.tsx
Normal file
60
frontend/src/components/sidebar/PinnedFlowsSection.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronDown, ChevronRight, Pin } from 'lucide-react'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
|
||||
interface PinnedFlowsSectionProps {
|
||||
flows: PinnedFlow[]
|
||||
onUnpin: (treeId: string) => void
|
||||
}
|
||||
|
||||
export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps) {
|
||||
const navigate = useNavigate()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex w-full items-center gap-1 px-3 mb-1 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
Pinned
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 max-h-[280px] overflow-y-auto">
|
||||
{flows.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Pin your most-used flows here
|
||||
</p>
|
||||
) : (
|
||||
flows.map(flow => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onUnpin(flow.tree_id)
|
||||
}}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
title={`${flow.tree_name} (right-click to unpin)`}
|
||||
>
|
||||
<span className="text-sm shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">{flow.tree_name}</span>
|
||||
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
frontend/src/components/sidebar/TagCloud.tsx
Normal file
38
frontend/src/components/sidebar/TagCloud.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TagCloudProps {
|
||||
tags: string[]
|
||||
activeTags?: string[]
|
||||
onTagClick: (tag: string) => void
|
||||
}
|
||||
|
||||
export function TagCloud({ tags, activeTags = [], onTagClick }: TagCloudProps) {
|
||||
if (tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-3 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground">
|
||||
Popular Tags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 px-3">
|
||||
{tags.map(tag => {
|
||||
const isActive = activeTags.includes(tag)
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => onTagClick(tag)}
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 font-label text-[0.625rem] transition-colors',
|
||||
isActive
|
||||
? 'border-primary/30 bg-primary/10 text-primary'
|
||||
: 'border-border bg-card text-muted-foreground hover:border-primary/20 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,13 +65,13 @@ export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepMod
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/80 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="relative flex h-[95vh] w-full max-w-full flex-col border border-white/[0.06] bg-[#0a0a0a] shadow-lg sm:h-[90vh] sm:max-w-4xl sm:rounded-2xl">
|
||||
<div className="relative flex h-[95vh] w-full max-w-full flex-col border border-border bg-card shadow-lg sm:h-[90vh] sm:max-w-4xl sm:rounded-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] p-4">
|
||||
<h2 className="text-lg font-semibold text-white">Add Custom Step</h2>
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Add Custom Step</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1.5 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -79,15 +79,15 @@ export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepMod
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/[0.06]">
|
||||
<div className="flex border-b border-border">
|
||||
{canCreateSteps && (
|
||||
<button
|
||||
onClick={() => setActiveTab('create')}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||||
activeTab === 'create'
|
||||
? 'border-b-2 border-white bg-white/5 text-white'
|
||||
: 'text-white/40 hover:bg-white/10 hover:text-white'
|
||||
? 'border-b-2 border-primary bg-primary/5 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Type My Own
|
||||
@@ -134,8 +134,8 @@ export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepMod
|
||||
{isSubmitting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||
<p className="text-sm text-white/40">Creating step...</p>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Creating step...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
|
||||
const remainingTags = step.tags.length - 3
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg border border-white/[0.06] bg-[#0a0a0a] p-4 transition-shadow hover:shadow-md">
|
||||
<div className="group rounded-lg border border-border bg-card p-4 transition-shadow hover:shadow-md">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
@@ -52,12 +52,12 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-white line-clamp-2">{step.title}</h3>
|
||||
<h3 className="font-semibold text-foreground line-clamp-2">{step.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mb-3 space-y-1.5 text-sm text-white/40">
|
||||
<div className="mb-3 space-y-1.5 text-sm text-muted-foreground">
|
||||
{/* Category */}
|
||||
{step.category_name && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -103,7 +103,7 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
|
||||
{visibleTags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70"
|
||||
className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -121,8 +121,8 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
|
||||
<button
|
||||
onClick={() => onPreview(step)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white transition-colors'
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground transition-colors'
|
||||
)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -131,8 +131,8 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
|
||||
<button
|
||||
onClick={() => onInsert(step)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 transition-colors'
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 transition-colors'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
||||
@@ -70,11 +70,11 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="relative flex h-[90vh] w-full max-w-3xl flex-col glass-card rounded-2xl shadow-lg">
|
||||
<div className="relative flex h-[90vh] w-full max-w-3xl flex-col bg-card border border-border rounded-2xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-white/[0.06] p-6 pb-4">
|
||||
<div className="flex items-start justify-between border-b border-border p-6 pb-4">
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-48 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-6 w-48 animate-pulse rounded bg-accent" />
|
||||
) : error ? (
|
||||
<h2 className="text-lg font-semibold text-red-400">{error}</h2>
|
||||
) : step ? (
|
||||
@@ -90,7 +90,7 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
{step.step_type}
|
||||
</span>
|
||||
{step.category_name && (
|
||||
<span className="text-xs text-white/40">📁 {step.category_name}</span>
|
||||
<span className="text-xs text-muted-foreground">{step.category_name}</span>
|
||||
)}
|
||||
{step.is_featured && (
|
||||
<span className="rounded bg-yellow-400/10 px-2 py-0.5 text-xs font-medium text-yellow-400">
|
||||
@@ -99,16 +99,16 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
)}
|
||||
{step.is_verified && (
|
||||
<span className="rounded bg-emerald-400/10 px-2 py-0.5 text-xs font-medium text-emerald-400">
|
||||
✓ Verified
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white">{step.title}</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">{step.title}</h2>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -119,18 +119,18 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-white/10" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-accent" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-accent" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-white/40">{error}</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
) : step ? (
|
||||
<div className="space-y-6">
|
||||
{/* Rating */}
|
||||
{hasRating && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-white">Rating</h3>
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">Rating</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
@@ -140,12 +140,12 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
'h-4 w-4',
|
||||
i <= Math.round(step.rating_average)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-white/20'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-white/70">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{step.rating_average.toFixed(1)} ({step.rating_count} {step.rating_count === 1 ? 'rating' : 'ratings'})
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,12 +155,12 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
{/* Tags */}
|
||||
{step.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-white">Tags</h3>
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{step.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-white/10 px-2 py-1 text-xs text-white/70"
|
||||
className="rounded-full bg-accent px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -172,7 +172,7 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
{/* Instructions */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold">Instructions</h3>
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/5 p-4">
|
||||
<div className="rounded-lg border border-border bg-accent/50 p-4">
|
||||
<MarkdownContent content={step.content.instructions} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,8 +180,8 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
{/* Help Text */}
|
||||
{step.content.help_text && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-white">Help Text</h3>
|
||||
<div className="rounded-lg border border-white/[0.06] bg-blue-400/5 p-4 text-sm">
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">Help Text</h3>
|
||||
<div className="rounded-lg border border-border bg-blue-400/5 p-4 text-sm">
|
||||
<MarkdownContent content={step.content.help_text} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,19 +190,19 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
{/* Commands */}
|
||||
{step.content.commands && step.content.commands.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-white">Commands</h3>
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">Commands</h3>
|
||||
<div className="space-y-2">
|
||||
{step.content.commands.map((cmd, index) => (
|
||||
<div key={index} className="group relative">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-white/40">{cmd.label}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{cmd.label}</span>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(cmd.command, index)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||
copiedCommandIndex === index
|
||||
? 'bg-emerald-400/10 text-emerald-400'
|
||||
: 'bg-white/10 text-white/40 hover:bg-white/20 hover:text-white'
|
||||
: 'bg-accent text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{copiedCommandIndex === index ? (
|
||||
@@ -218,7 +218,7 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-black/50 p-3 text-xs text-white">
|
||||
<pre className="overflow-x-auto rounded bg-card p-3 text-xs text-foreground">
|
||||
<code>{cmd.command}</code>
|
||||
</pre>
|
||||
</div>
|
||||
@@ -231,23 +231,23 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
{topReviews.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white">Reviews</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">Reviews</h3>
|
||||
{reviews.length > 3 && (
|
||||
<button className="text-xs text-white/70 hover:text-white hover:underline">
|
||||
<button className="text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||
See all {reviews.length} reviews
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{topReviews.map(review => (
|
||||
<div key={review.id} className="rounded-lg border border-white/[0.06] bg-white/5 p-3">
|
||||
<div key={review.id} className="rounded-lg border border-border bg-accent/50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-white">{review.user_name || 'Anonymous'}</span>
|
||||
<span className="font-medium text-foreground">{review.user_name || 'Anonymous'}</span>
|
||||
{review.verified_use && (
|
||||
<span className="rounded bg-emerald-400/10 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||
✓ Verified Use
|
||||
Verified Use
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,14 +259,14 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
'h-3 w-3',
|
||||
i <= review.rating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-white/20'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/70">{review.review_text}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-white/40">
|
||||
<p className="text-sm text-muted-foreground">{review.review_text}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(review.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
@@ -277,22 +277,22 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/5 p-4">
|
||||
<div className="rounded-lg border border-border bg-accent/50 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-white/40">Author:</span>
|
||||
<span className="ml-2 font-medium text-white">{step.author_name || 'Unknown'}</span>
|
||||
<span className="text-muted-foreground">Author:</span>
|
||||
<span className="ml-2 font-medium text-foreground">{step.author_name || 'Unknown'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/40">Usage Count:</span>
|
||||
<span className="ml-2 font-medium text-white">{step.usage_count}</span>
|
||||
<span className="text-muted-foreground">Usage Count:</span>
|
||||
<span className="ml-2 font-medium text-foreground">{step.usage_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/40">Created:</span>
|
||||
<span className="ml-2 font-medium text-white">{new Date(step.created_at).toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground">Created:</span>
|
||||
<span className="ml-2 font-medium text-foreground">{new Date(step.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/40">Visibility:</span>
|
||||
<span className="text-muted-foreground">Visibility:</span>
|
||||
<span className="ml-2 font-medium capitalize">{step.visibility}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,10 +302,10 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
</div>
|
||||
|
||||
{/* Footer - Actions */}
|
||||
<div className="flex gap-2 border-t border-white/[0.06] p-4">
|
||||
<div className="flex gap-2 border-t border-border p-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -313,8 +313,8 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
||||
onClick={handleInsert}
|
||||
disabled={!step}
|
||||
className={cn(
|
||||
'flex-1 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50'
|
||||
'flex-1 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Insert Into Session
|
||||
|
||||
@@ -136,7 +136,7 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Step Type */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Step Type <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -150,15 +150,15 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
className={cn(
|
||||
'rounded-lg border p-3 text-left transition-colors',
|
||||
stepType === option.value
|
||||
? 'border-white/20 bg-white/10 ring-2 ring-white/20'
|
||||
: 'border-white/[0.06] hover:border-white/20'
|
||||
? 'border-border bg-accent ring-2 ring-primary/20'
|
||||
: 'border-border hover:border-border'
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="font-medium text-sm text-white">{option.label}</span>
|
||||
<span className="font-medium text-sm text-foreground">{option.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/40">{option.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{option.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -167,7 +167,7 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 block text-sm font-medium text-white">
|
||||
<label htmlFor="title" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Title <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -177,8 +177,8 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter step title"
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20',
|
||||
errors.title ? 'border-red-400/50' : 'border-white/10'
|
||||
'w-full rounded-md border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20',
|
||||
errors.title ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{errors.title && (
|
||||
@@ -188,9 +188,9 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
|
||||
{/* Instructions */}
|
||||
<div>
|
||||
<label htmlFor="instructions" className="mb-2 block text-sm font-medium text-white">
|
||||
<label htmlFor="instructions" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Instructions <span className="text-red-400">*</span>
|
||||
<span className="ml-2 text-xs font-normal text-white/40">(Markdown supported)</span>
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">(Markdown supported)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="instructions"
|
||||
@@ -199,8 +199,8 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
placeholder="Describe what to do in this step..."
|
||||
rows={6}
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20',
|
||||
errors.instructions ? 'border-red-400/50' : 'border-white/10'
|
||||
'w-full rounded-md border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20',
|
||||
errors.instructions ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{errors.instructions && (
|
||||
@@ -210,8 +210,8 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
|
||||
{/* Help Text */}
|
||||
<div>
|
||||
<label htmlFor="helpText" className="mb-2 block text-sm font-medium text-white">
|
||||
Help Text <span className="text-xs font-normal text-white/40">(Optional)</span>
|
||||
<label htmlFor="helpText" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Help Text <span className="text-xs font-normal text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="helpText"
|
||||
@@ -219,20 +219,20 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
onChange={(e) => setHelpText(e.target.value)}
|
||||
placeholder="Additional context or tips..."
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Commands */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-white">
|
||||
Commands <span className="text-xs font-normal text-white/40">(Optional)</span>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Commands <span className="text-xs font-normal text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCommand}
|
||||
className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 text-xs font-medium text-white/70 hover:bg-white/20 hover:text-white"
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Command
|
||||
@@ -241,13 +241,13 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
{commands.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{commands.map((cmd, index) => (
|
||||
<div key={index} className="rounded-lg border border-white/[0.06] bg-white/5 p-3">
|
||||
<div key={index} className="rounded-lg border border-border bg-accent/50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-white/40">Command {index + 1}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">Command {index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCommand(index)}
|
||||
className="rounded p-1 text-white/40 hover:bg-red-400/10 hover:text-red-400"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-red-400/10 hover:text-red-400"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -259,8 +259,8 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
onChange={(e) => updateCommand(index, 'label', e.target.value)}
|
||||
placeholder="Command label (e.g., 'Restart service')"
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-black/50 px-3 py-1.5 text-sm text-white',
|
||||
errors[`command_${index}_label`] ? 'border-red-400/50' : 'border-white/10'
|
||||
'w-full rounded-md border bg-card px-3 py-1.5 text-sm text-foreground',
|
||||
errors[`command_${index}_label`] ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
@@ -269,8 +269,8 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
onChange={(e) => updateCommand(index, 'command', e.target.value)}
|
||||
placeholder="Command (e.g., 'systemctl restart nginx')"
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-black/50 px-3 py-1.5 font-mono text-sm text-white',
|
||||
errors[`command_${index}_command`] ? 'border-red-400/50' : 'border-white/10'
|
||||
'w-full rounded-md border bg-card px-3 py-1.5 font-mono text-sm text-foreground',
|
||||
errors[`command_${index}_command`] ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{(errors[`command_${index}_label`] || errors[`command_${index}_command`]) && (
|
||||
@@ -287,14 +287,14 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label htmlFor="category" className="mb-2 block text-sm font-medium text-white">
|
||||
Category <span className="text-xs font-normal text-white/40">(Optional)</span>
|
||||
<label htmlFor="category" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Category <span className="text-xs font-normal text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className="w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{categories.map(cat => (
|
||||
@@ -305,8 +305,8 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label htmlFor="tagInput" className="mb-2 block text-sm font-medium text-white">
|
||||
Tags <span className="text-xs font-normal text-white/40">(Optional)</span>
|
||||
<label htmlFor="tagInput" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Tags <span className="text-xs font-normal text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -316,12 +316,12 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
placeholder="Type tag and press Enter"
|
||||
className="flex-1 rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="rounded-md bg-white/10 px-4 py-2 text-sm font-medium text-white/70 hover:bg-white/20 hover:text-white"
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
@@ -331,13 +331,13 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-xs text-white/70"
|
||||
className="flex items-center gap-1 rounded-full bg-accent px-2.5 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="rounded-full hover:bg-white/20"
|
||||
className="rounded-full hover:bg-accent"
|
||||
aria-label={`Remove tag ${tag}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -350,14 +350,14 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label htmlFor="visibility" className="mb-2 block text-sm font-medium text-white">
|
||||
<label htmlFor="visibility" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="visibility"
|
||||
value={visibility}
|
||||
onChange={(e) => setVisibility(e.target.value as 'private' | 'team' | 'public')}
|
||||
className="w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="private">Private (only me)</option>
|
||||
<option value="team">Team (my team members)</option>
|
||||
@@ -370,13 +370,13 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
|
||||
className="flex-1 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
Insert Step
|
||||
</button>
|
||||
|
||||
@@ -133,16 +133,16 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header - Filters */}
|
||||
<div className="space-y-4 border-b border-white/[0.06] p-4">
|
||||
<div className="space-y-4 border-b border-border p-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search steps..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-white/10 bg-black/50 py-2 pl-10 pr-4 text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="w-full rounded-md border border-border bg-card py-2 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
aria-label="Filter by category"
|
||||
value={selectedCategoryId || ''}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value || undefined)}
|
||||
className="rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map(cat => (
|
||||
@@ -166,7 +166,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
aria-label="Filter by step type"
|
||||
value={selectedStepType || ''}
|
||||
onChange={(e) => setSelectedStepType((e.target.value as 'decision' | 'action' | 'solution') || undefined)}
|
||||
className="rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="decision">Decision</option>
|
||||
@@ -179,7 +179,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
aria-label="Filter by minimum rating"
|
||||
value={minRating?.toString() || ''}
|
||||
onChange={(e) => setMinRating(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">Any Rating</option>
|
||||
<option value="4">4+ Stars</option>
|
||||
@@ -192,7 +192,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
aria-label="Sort steps by"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'recent' | 'popular' | 'highest_rated' | 'most_used')}
|
||||
className="rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20"
|
||||
className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="recent">Most Recent</option>
|
||||
<option value="popular">Most Popular</option>
|
||||
@@ -204,7 +204,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
{/* Popular Tags */}
|
||||
{popularTags.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-white/40">Popular Tags:</div>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Popular Tags:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{popularTags.map(tag => (
|
||||
<button
|
||||
@@ -213,8 +213,8 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-1 text-xs transition-colors',
|
||||
selectedTag === tag.tag
|
||||
? 'bg-white text-black'
|
||||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
? 'bg-gradient-brand text-white shadow-lg shadow-primary/20'
|
||||
: 'bg-accent text-muted-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{tag.tag} ({tag.count})
|
||||
@@ -228,7 +228,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-white/70 hover:text-white hover:underline"
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
@@ -239,16 +239,16 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/10 p-4 text-center text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
) : steps.length === 0 ? (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/5 p-12 text-center">
|
||||
<p className="mb-2 text-lg font-medium text-white">No steps found</p>
|
||||
<p className="text-sm text-white/40">
|
||||
<div className="rounded-lg border border-border bg-accent/50 p-12 text-center">
|
||||
<p className="mb-2 text-lg font-medium text-foreground">No steps found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasActiveFilters ? 'Try adjusting your filters' : 'Create your first step to get started!'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -261,7 +261,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
onClick={() => toggleSection('private')}
|
||||
className="mb-3 flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-white">My Steps ({groupedSteps.private.length})</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">My Steps ({groupedSteps.private.length})</h3>
|
||||
{collapsedSections.private ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
@@ -290,7 +290,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
onClick={() => toggleSection('team')}
|
||||
className="mb-3 flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-white">Team Steps ({groupedSteps.team.length})</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">Team Steps ({groupedSteps.team.length})</h3>
|
||||
{collapsedSections.team ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
@@ -319,7 +319,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
onClick={() => toggleSection('public')}
|
||||
className="mb-3 flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-white">Community ({groupedSteps.public.length})</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">Community ({groupedSteps.public.length})</h3>
|
||||
{collapsedSections.public ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
@@ -346,10 +346,10 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
|
||||
{/* Footer - Optional Create Button */}
|
||||
{showCreateButton && onCreateNew && (
|
||||
<div className="border-t border-white/[0.06] p-4">
|
||||
<div className="border-t border-border p-4">
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="w-full rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
|
||||
className="w-full rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
+ Create New Step
|
||||
</button>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function DynamicArrayField<T>({
|
||||
type="button"
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className="rounded p-0.5 text-white/50 hover:bg-white/[0.06] hover:text-white disabled:opacity-30"
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
|
||||
title="Move up"
|
||||
aria-label="Move up"
|
||||
>
|
||||
@@ -61,7 +61,7 @@ export function DynamicArrayField<T>({
|
||||
type="button"
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === items.length - 1}
|
||||
className="rounded p-0.5 text-white/50 hover:bg-white/[0.06] hover:text-white disabled:opacity-30"
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
|
||||
title="Move down"
|
||||
aria-label="Move down"
|
||||
>
|
||||
@@ -78,7 +78,7 @@ export function DynamicArrayField<T>({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="mt-1 rounded p-1 text-white/50 hover:bg-red-400/20 hover:text-red-400"
|
||||
className="mt-1 rounded p-1 text-muted-foreground hover:bg-red-400/20 hover:text-red-400"
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
>
|
||||
@@ -94,9 +94,9 @@ export function DynamicArrayField<T>({
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-white/10',
|
||||
'px-3 py-2 text-sm text-white/50',
|
||||
'hover:border-white/30 hover:text-white'
|
||||
'flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border',
|
||||
'px-3 py-2 text-sm text-muted-foreground',
|
||||
'hover:border-border hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -106,7 +106,7 @@ export function DynamicArrayField<T>({
|
||||
|
||||
{/* Empty state */}
|
||||
{items.length === 0 && !canAdd && (
|
||||
<p className="text-center text-sm text-white/40">No items</p>
|
||||
<p className="text-center text-sm text-muted-foreground">No items</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -68,14 +68,14 @@ export function NodeEditorModal({ node, onClose, isNewNode = false }: NodeEditor
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
|
||||
className="rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
@@ -85,8 +85,8 @@ export function NodeEditorModal({ node, onClose, isNewNode = false }: NodeEditor
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
|
||||
{/* Node ID display */}
|
||||
<div className="mb-4 text-xs text-white/40">
|
||||
Node ID: <code className="rounded bg-white/10 px-1 py-0.5">{node.id}</code>
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
Node ID: <code className="rounded bg-accent px-1 py-0.5">{node.id}</code>
|
||||
</div>
|
||||
|
||||
{/* Validation errors */}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Title <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -62,9 +62,9 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
placeholder="e.g., Restart the Service"
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
titleError ? 'border-red-400' : 'border-white/10'
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
titleError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{titleError && (
|
||||
@@ -75,24 +75,24 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
{/* Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Description
|
||||
</label>
|
||||
{node.description && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="text-xs text-white/50 hover:text-white hover:underline"
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{showPreview ? 'Edit' : 'Preview'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-1 text-xs text-white/40">
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
|
||||
</p>
|
||||
{showPreview && node.description ? (
|
||||
<div className="mt-1 rounded-md border border-white/10 bg-white/[0.04] p-3 text-sm">
|
||||
<div className="mt-1 rounded-md border border-border bg-accent/50 p-3 text-sm">
|
||||
<MarkdownContent content={node.description} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -108,7 +108,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
**Note:** Important information here"
|
||||
rows={5}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
@@ -118,10 +118,10 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
|
||||
{/* Commands */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Commands
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-white/40">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
PowerShell or CLI commands to execute
|
||||
</p>
|
||||
<DynamicArrayField
|
||||
@@ -137,7 +137,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
onChange={(e) => handleUpdateCommand(index, e.target.value)}
|
||||
placeholder="e.g., Get-Service BrokerAgent"
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-white/10 px-3 py-2 font-mono text-sm',
|
||||
'block w-full rounded-md border border-border px-3 py-2 font-mono text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
@@ -148,7 +148,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Expected Outcome
|
||||
</label>
|
||||
<input
|
||||
@@ -157,7 +157,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
onChange={(e) => onUpdate({ expected_outcome: e.target.value })}
|
||||
placeholder="e.g., Service should show as Running"
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
<h3 className="font-semibold text-blue-400">
|
||||
Starting Question
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This is the first question users will see when they start this troubleshooting tree.
|
||||
Each option below creates a different troubleshooting path.
|
||||
</p>
|
||||
@@ -78,11 +78,11 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
|
||||
{/* Question */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{isRootNode ? 'Starting Question' : 'Question'} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
{isRootNode && (
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
What's the main question to diagnose the issue?
|
||||
</p>
|
||||
)}
|
||||
@@ -95,9 +95,9 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
: "e.g., Can you ping the server?"}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
questionError ? 'border-red-400' : 'border-white/10'
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
questionError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{questionError && (
|
||||
@@ -107,7 +107,7 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
|
||||
{/* Help Text */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Help Text
|
||||
</label>
|
||||
<textarea
|
||||
@@ -116,7 +116,7 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
placeholder="Additional context or instructions for this decision..."
|
||||
rows={2}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
@@ -125,15 +125,15 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
{isRootNode ? (
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Each option can branch to a different next step.
|
||||
</p>
|
||||
)}
|
||||
@@ -158,14 +158,14 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
const letter = indexToLetter(index)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-white/10 bg-white/[0.04] p-3">
|
||||
<div className="rounded-md border border-border bg-accent/50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{/* Letter badge */}
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
|
||||
isRootNode
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-white/10 text-white/50'
|
||||
: 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{letter}
|
||||
</span>
|
||||
@@ -180,7 +180,7 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
'block flex-1 rounded-md border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
optionLabelError ? 'border-red-400' : 'border-white/10'
|
||||
optionLabelError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -207,7 +207,7 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
|
||||
{/* Example hint for root node */}
|
||||
{isRootNode && (node.options?.length || 0) < 2 && (
|
||||
<div className="mt-3 rounded-md border border-dashed border-white/10 bg-white/[0.02] p-3 text-xs text-white/40">
|
||||
<div className="mt-3 rounded-md border border-dashed border-border bg-accent/50 p-3 text-xs text-muted-foreground">
|
||||
<strong>Tip:</strong> Most troubleshooting trees start with 2-5 main branches.
|
||||
For example: "Connection Issues", "Performance Problems", "Error Messages", "Other".
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps)
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Title <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -57,9 +57,9 @@ export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps)
|
||||
placeholder="e.g., VDA Successfully Registered"
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
titleError ? 'border-red-400' : 'border-white/10'
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
titleError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{titleError && (
|
||||
@@ -70,24 +70,24 @@ export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps)
|
||||
{/* Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Description
|
||||
</label>
|
||||
{node.description && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="text-xs text-white/50 hover:text-white hover:underline"
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{showPreview ? 'Edit' : 'Preview'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-1 text-xs text-white/40">
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
|
||||
</p>
|
||||
{showPreview && node.description ? (
|
||||
<div className="mt-1 rounded-md border border-white/10 bg-white/[0.04] p-3 text-sm">
|
||||
<div className="mt-1 rounded-md border border-border bg-accent/50 p-3 text-sm">
|
||||
<MarkdownContent content={node.description} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -102,7 +102,7 @@ Document what was done and the outcome.
|
||||
**Close ticket as:** Resolved"
|
||||
rows={5}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
@@ -112,10 +112,10 @@ Document what was done and the outcome.
|
||||
|
||||
{/* Resolution Steps */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Resolution Steps
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-white/40">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Step-by-step instructions for resolving the issue
|
||||
</p>
|
||||
<DynamicArrayField
|
||||
@@ -135,7 +135,7 @@ Document what was done and the outcome.
|
||||
onChange={(e) => handleUpdateStep(index, e.target.value)}
|
||||
placeholder={`Step ${index + 1}`}
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
|
||||
@@ -95,9 +95,9 @@ function NodeListItem({
|
||||
}
|
||||
|
||||
const nodeTypeColors: Record<NodeType, string> = {
|
||||
decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
|
||||
solution: 'bg-green-500/20 text-green-600 dark:text-green-400'
|
||||
decision: 'bg-blue-500/20 text-blue-400',
|
||||
action: 'bg-yellow-500/20 text-yellow-400',
|
||||
solution: 'bg-green-500/20 text-green-400'
|
||||
}
|
||||
|
||||
const getNodeLabel = () => {
|
||||
@@ -223,7 +223,7 @@ function NodeListItem({
|
||||
|
||||
{/* Node type icon - special treatment for root */}
|
||||
{isRootNode ? (
|
||||
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-500/30 text-blue-600 dark:text-blue-400 font-semibold">
|
||||
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-500/30 text-blue-400 font-semibold">
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">START</span>
|
||||
</span>
|
||||
@@ -254,7 +254,7 @@ function NodeListItem({
|
||||
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs',
|
||||
hasError
|
||||
? 'bg-destructive/20 text-destructive'
|
||||
: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-500'
|
||||
: 'bg-yellow-500/20 text-yellow-500'
|
||||
)}
|
||||
>
|
||||
{hasError ? (
|
||||
|
||||
@@ -11,9 +11,9 @@ const CREATE_SOLUTION = `${CREATE_PREFIX}solution__`
|
||||
|
||||
// Unicode symbols for node types (works in select options)
|
||||
const NODE_TYPE_SYMBOLS: Record<NodeType, string> = {
|
||||
decision: 'ⓘ', // Information/question symbol
|
||||
action: '⚡', // Lightning bolt for action
|
||||
solution: '✓' // Checkmark for solution
|
||||
decision: '\u24D8', // Information/question symbol
|
||||
action: '\u26A1', // Lightning bolt for action
|
||||
solution: '\u2713' // Checkmark for solution
|
||||
}
|
||||
|
||||
// Node type labels for UI
|
||||
@@ -139,7 +139,7 @@ export function NodePicker({
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="mb-1 block text-sm font-medium text-white">
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
@@ -147,8 +147,8 @@ export function NodePicker({
|
||||
{/* Inline node creation UI */}
|
||||
{creatingNodeType ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 rounded-md border border-white/20 bg-white/[0.04] p-2">
|
||||
<span className="text-xs font-medium text-white">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-accent/50 p-2">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
New {NODE_TYPE_LABELS[creatingNodeType]}:
|
||||
</span>
|
||||
<input
|
||||
@@ -159,9 +159,9 @@ export function NodePicker({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={creatingNodeType === 'decision' ? 'Enter question...' : 'Enter title...'}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-white/10 px-2 py-1 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'flex-1 rounded-md border border-border px-2 py-1 text-sm',
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ export function NodePicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelCreate}
|
||||
className="flex-1 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="flex-1 rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -179,7 +179,7 @@ export function NodePicker({
|
||||
disabled={!newNodeTitle.trim()}
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-3 py-1.5 text-xs font-medium',
|
||||
'bg-white text-black hover:bg-white/90',
|
||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
@@ -194,9 +194,9 @@ export function NodePicker({
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className={cn(
|
||||
'block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
error ? 'border-red-400' : 'border-white/10'
|
||||
'bg-card text-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
error ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
@@ -242,7 +242,7 @@ export function NodePicker({
|
||||
|
||||
{/* Show what's selected */}
|
||||
{value && selectedNode && (
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
→ {selectedNode.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -28,12 +28,12 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
<>
|
||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-hidden border-white/[0.06]',
|
||||
'flex flex-col overflow-hidden border-border',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<Suspense fallback={
|
||||
<div className="flex h-full items-center justify-center bg-[#0a0a0a]">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
<div className="flex h-full items-center justify-center bg-card">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<CodeModeEditor />
|
||||
@@ -42,7 +42,7 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
'flex-1 overflow-hidden bg-accent/50',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
@@ -52,7 +52,7 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
<>
|
||||
{/* Flow Mode: Form editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-y-auto border-white/[0.06]',
|
||||
'flex flex-col overflow-y-auto border-border',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<div className="space-y-4 p-4">
|
||||
@@ -63,7 +63,7 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
'flex-1 overflow-hidden bg-accent/50',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
|
||||
@@ -56,12 +56,12 @@ export function TreeMetadataForm() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 glass-card rounded-2xl p-4">
|
||||
<h2 className="text-sm font-semibold text-white">Tree Details</h2>
|
||||
<div className="space-y-4 bg-card border border-border rounded-2xl p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Tree Details</h2>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="tree-name" className="block text-sm font-medium text-white">
|
||||
<label htmlFor="tree-name" className="block text-sm font-medium text-foreground">
|
||||
Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -72,9 +72,9 @@ export function TreeMetadataForm() {
|
||||
placeholder="e.g., VDA Registration Troubleshooting"
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
nameError ? 'border-red-400' : 'border-white/10'
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
nameError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{nameError && <p className="mt-1 text-xs text-red-400">{nameError.message}</p>}
|
||||
@@ -82,7 +82,7 @@ export function TreeMetadataForm() {
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="tree-description" className="block text-sm font-medium text-white">
|
||||
<label htmlFor="tree-description" className="block text-sm font-medium text-foreground">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
@@ -92,16 +92,16 @@ export function TreeMetadataForm() {
|
||||
placeholder="Brief description of what this tree troubleshoots..."
|
||||
rows={2}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label htmlFor="tree-category" className="block text-sm font-medium text-white">
|
||||
<label htmlFor="tree-category" className="block text-sm font-medium text-foreground">
|
||||
Category
|
||||
</label>
|
||||
{!customCategory ? (
|
||||
@@ -110,9 +110,9 @@ export function TreeMetadataForm() {
|
||||
value={categoryId || ''}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-card text-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="">No category</option>
|
||||
@@ -132,9 +132,9 @@ export function TreeMetadataForm() {
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="Enter new category"
|
||||
className={cn(
|
||||
'block flex-1 rounded-md border border-white/10 px-3 py-2 text-sm',
|
||||
'bg-black/50 text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
'block flex-1 rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -144,7 +144,7 @@ export function TreeMetadataForm() {
|
||||
setCategory('')
|
||||
setCategoryId(null)
|
||||
}}
|
||||
className="rounded-md border border-white/10 px-3 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
className="rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -154,7 +154,7 @@ export function TreeMetadataForm() {
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">Tags</label>
|
||||
<label className="block text-sm font-medium text-foreground">Tags</label>
|
||||
<div className="mt-1">
|
||||
<TagInput tags={tags} onChange={setTags} maxTags={10} placeholder="Add tags..." />
|
||||
</div>
|
||||
@@ -162,13 +162,13 @@ export function TreeMetadataForm() {
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">Visibility</label>
|
||||
<label className="block text-sm font-medium text-foreground">Visibility</label>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<label
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md border px-4 py-2',
|
||||
'transition-colors',
|
||||
!isPublic ? 'border-white/30 bg-white/10 text-white' : 'border-white/10 text-white/60 hover:bg-white/[0.06]'
|
||||
!isPublic ? 'border-primary/30 bg-accent text-foreground' : 'border-border text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -185,7 +185,7 @@ export function TreeMetadataForm() {
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md border px-4 py-2',
|
||||
'transition-colors',
|
||||
isPublic ? 'border-white/30 bg-white/10 text-white' : 'border-white/10 text-white/60 hover:bg-white/[0.06]'
|
||||
isPublic ? 'border-primary/30 bg-accent text-foreground' : 'border-border text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -199,7 +199,7 @@ export function TreeMetadataForm() {
|
||||
<span className="text-sm">Public</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{isPublic
|
||||
? 'Anyone can view this tree'
|
||||
: 'Only you and your team can view this tree'}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ValidationSummary({ errors, onSelectNode }: ValidationSummaryPro
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-white/5',
|
||||
'flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-accent',
|
||||
errorItems.length > 0 ? 'text-red-400' : 'text-yellow-400'
|
||||
)}
|
||||
>
|
||||
@@ -81,7 +81,7 @@ export function ValidationSummary({ errors, onSelectNode }: ValidationSummaryPro
|
||||
<div className="flex-1">
|
||||
<p className="text-red-400">{error.message}</p>
|
||||
{error.nodeId && (
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Click to select node: {error.nodeId}
|
||||
</p>
|
||||
)}
|
||||
@@ -105,7 +105,7 @@ export function ValidationSummary({ errors, onSelectNode }: ValidationSummaryPro
|
||||
<div className="flex-1">
|
||||
<p className="text-yellow-400">{warning.message}</p>
|
||||
{warning.nodeId && (
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Click to select node: {warning.nodeId}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -166,8 +166,8 @@ export function CodeModeEditor() {
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorDidMount}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center bg-[#0a0a0a]">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
<div className="flex h-full items-center justify-center bg-card">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user