diff --git a/IMPLEMENTATION_ROADMAP.md b/IMPLEMENTATION_ROADMAP.md
index 0603b2d..4634085 100644
--- a/IMPLEMENTATION_ROADMAP.md
+++ b/IMPLEMENTATION_ROADMAP.md
@@ -458,20 +458,34 @@ frontend/src/components/admin/content/PagesManager.tsx
frontend/src/components/admin/content/ArticlesManager.tsx
```
-**Command**:
-```bash
-cd /Users/christopheramaya/Downloads/spark/frontend/src
-mkdir -p pages/admin/sites
-mkdir -p components/admin/sites
-mkdir -p components/admin/content
-touch pages/admin/sites/index.astro
-touch pages/admin/content/posts.astro
-touch pages/admin/content/pages.astro
-touch components/admin/sites/SitesManager.tsx
-touch components/admin/content/PostsManager.tsx
-touch components/admin/content/PagesManager.tsx
-touch components/admin/content/ArticlesManager.tsx
-```
+---
+
+## šÆ MILESTONE 4: LAUNCHPAD - SITE BUILDER
+
+**Goal**: Build a fully functional site builder for managing sites, pages, navigation, and global settings.
+
+#### Task 4.1: Sites Manager & Dashboard ā
(COMPLETED)
+**What Was Built**:
+- ā
Sites Collection & Manager
+- ā
Site Dashboard with Tabs (Pages, Nav, Theme)
+- ā
Launchpad Schema Setup
+
+#### Task 4.2: Page Builder ā
(COMPLETED)
+**What Was Built**:
+- ā
Block-based Page Editor (Hero, Content, Features)
+- ā
Real-time JSON state management
+- ā
Draft/Published status workflow
+- ā
/admin/sites/editor/[id]
+
+#### Task 4.3: Navigation & Globals ā
(COMPLETED)
+**What Was Built**:
+- ā
Navigation Editor (Add/Sort links)
+- ā
Theme Settings (Colors, Logo, Footer)
+- ā
Global singleton schema
+
+#### Task 4.4: Launchpad Frontend
+**What to Build**:
+- Integrate Next.js frontend to fetch this data (Future Phase)
---
diff --git a/TODAY_VS_FUTURE_PLAN.md b/TODAY_VS_FUTURE_PLAN.md
index aee1f81..99ed2b4 100644
--- a/TODAY_VS_FUTURE_PLAN.md
+++ b/TODAY_VS_FUTURE_PLAN.md
@@ -19,14 +19,19 @@
- **Kanban Board**: Drag & Drop Article Pipeline + Backend Schema Setup ā
- **Leads Manager**: CRM Table + Status Workflow + Backend Schema ā
- **Jobs Queue**: Real-time Monitoring + Action Controls + Config Viewer ā
-- **File Structure**: 35+ components created and organized ā
+- **Launchpad**: Sites + Pages + Navigation + Theme Managers (Complete Site Builder) ā
+- **Page Editor**: Visual Block Editor saving to JSON ā
+- **File Structure**: 45+ components created and organized ā
**Files Modified**:
- `components/admin/intelligence/*` (Complete Suite)
- `components/admin/factory/*` (Kanban + Jobs)
- `components/admin/leads/*` (Leads)
+- `components/admin/sites/*` (Launchpad)
- `pages/admin/factory/*`
- `pages/admin/leads/*`
+- `pages/admin/sites/**/*`
+
diff --git a/backend/scripts/setup_launchpad_schema.ts b/backend/scripts/setup_launchpad_schema.ts
new file mode 100644
index 0000000..e088f6b
--- /dev/null
+++ b/backend/scripts/setup_launchpad_schema.ts
@@ -0,0 +1,122 @@
+import { createDirectus, rest, authentication, createCollection, createField } from '@directus/sdk';
+import * as dotenv from 'dotenv';
+import * as path from 'path';
+
+dotenv.config({ path: path.resolve(__dirname, '../credentials.env') });
+
+const DIRECTUS_URL = process.env.DIRECTUS_PUBLIC_URL || 'https://spark.jumpstartscaling.com';
+const EMAIL = process.env.DIRECTUS_ADMIN_EMAIL;
+const PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD;
+
+const client = createDirectus(DIRECTUS_URL).with(authentication()).with(rest());
+
+async function setupLaunchpadSchema() {
+ console.log(`š Connecting to Directus at ${DIRECTUS_URL}...`);
+
+ try {
+ await client.login(EMAIL!, PASSWORD!);
+ console.log('ā
Authentication successful.');
+
+ // 1. Sites Collection
+ console.log('\n--- Setting up Sites ---');
+ try {
+ await client.request(createCollection({
+ collection: 'sites',
+ schema: {},
+ meta: {
+ icon: 'public',
+ note: 'Multi-site management',
+ display_template: '{{name}} ({{domain}})'
+ }
+ }));
+ console.log(' ā
Collection created: sites');
+ } catch (e: any) { console.log(' āļø Collection exists: sites'); }
+
+ const siteFields = [
+ { field: 'name', type: 'string' },
+ { field: 'domain', type: 'string', meta: { note: 'Primary domain (e.g. example.com)' } },
+ { field: 'status', type: 'string', meta: { interface: 'select-dropdown', options: { choices: [{ text: 'Active', value: 'active' }, { text: 'Inactive', value: 'inactive' }] } }, schema: { default_value: 'active' } },
+ { field: 'settings', type: 'json', meta: { interface: 'code', options: { language: 'json' }, note: 'Advanced config' } }
+ ];
+
+ for (const f of siteFields) {
+ try {
+ // @ts-ignore
+ await client.request(createField('sites', f));
+ console.log(` ā
Field: sites.${f.field}`);
+ } catch (e) { }
+ }
+
+ // 2. Pages Collection
+ console.log('\n--- Setting up Pages ---');
+ try {
+ await client.request(createCollection({
+ collection: 'pages',
+ schema: {},
+ meta: {
+ icon: 'pages',
+ note: 'Website pages',
+ display_template: '{{title}}'
+ }
+ }));
+ console.log(' ā
Collection created: pages');
+ } catch (e) { console.log(' āļø Collection exists: pages'); }
+
+ const pageFields = [
+ { field: 'title', type: 'string' },
+ { field: 'permalink', type: 'string', meta: { note: '/slug' } },
+ { field: 'status', type: 'string', schema: { default_value: 'draft' } },
+ { field: 'blocks', type: 'json', meta: { interface: 'list', note: 'JSON structure of page blocks' } }, // Using JSON for blocks primarily for flexibility
+ { field: 'seo_title', type: 'string' },
+ { field: 'seo_description', type: 'text' },
+ { field: 'site', type: 'integer', meta: { interface: 'select-dropdown' }, schema: { is_nullable: true } } // Simplified relationship
+ ];
+
+ for (const f of pageFields) {
+ try {
+ // @ts-ignore
+ await client.request(createField('pages', f));
+ console.log(` ā
Field: pages.${f.field}`);
+ } catch (e) { }
+ }
+
+ // 3. Globals (Theme Settings)
+ console.log('\n--- Setting up Globals ---');
+ try {
+ await client.request(createCollection({
+ collection: 'globals',
+ schema: {},
+ meta: {
+ icon: 'settings_suggest',
+ singleton: true, // Only one record usually per site context, but we might want multiple for multi-site
+ note: 'Global site settings'
+ }
+ }));
+ console.log(' ā
Collection created: globals');
+ } catch (e) { console.log(' āļø Collection exists: globals'); }
+
+ const globalFields = [
+ { field: 'site', type: 'integer' },
+ { field: 'logo', type: 'uuid', meta: { interface: 'file-image' } }, // Assuming directus_files
+ { field: 'primary_color', type: 'string', meta: { interface: 'color' } },
+ { field: 'secondary_color', type: 'string', meta: { interface: 'color' } },
+ { field: 'footer_text', type: 'text' },
+ { field: 'social_links', type: 'json', meta: { interface: 'list' } }
+ ];
+
+ for (const f of globalFields) {
+ try {
+ // @ts-ignore
+ await client.request(createField('globals', f));
+ console.log(` ā
Field: globals.${f.field}`);
+ } catch (e) { }
+ }
+
+ console.log('\nā
Launchpad Schema Setup Complete!');
+
+ } catch (error) {
+ console.error('ā Failed:', error);
+ }
+}
+
+setupLaunchpadSchema();
diff --git a/frontend/src/components/admin/sites/NavigationManager.tsx b/frontend/src/components/admin/sites/NavigationManager.tsx
new file mode 100644
index 0000000..888a185
--- /dev/null
+++ b/frontend/src/components/admin/sites/NavigationManager.tsx
@@ -0,0 +1,141 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Plus, Trash2, Save, GripVertical } from 'lucide-react';
+import { toast } from 'sonner';
+
+const client = getDirectusClient();
+
+interface NavItem {
+ id: string;
+ label: string;
+ url: string;
+ sort: number;
+}
+
+interface NavigationManagerProps {
+ siteId: string;
+}
+
+export default function NavigationManager({ siteId }: NavigationManagerProps) {
+ const queryClient = useQueryClient();
+ const [newItem, setNewItem] = useState({ label: '', url: '' });
+
+ const { data: items = [], isLoading } = useQuery({
+ queryKey: ['navigation', siteId],
+ queryFn: async () => {
+ // @ts-ignore
+ const res = await client.request(readItems('navigation', {
+ filter: { site: { _eq: siteId } },
+ sort: ['sort']
+ }));
+ return res as unknown as NavItem[];
+ }
+ });
+
+ const createMutation = useMutation({
+ mutationFn: async () => {
+ // @ts-ignore
+ await client.request(createItem('navigation', {
+ ...newItem,
+ site: siteId,
+ sort: items.length + 1
+ }));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
+ setNewItem({ label: '', url: '' });
+ toast.success('Menu item added');
+ }
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: string) => {
+ // @ts-ignore
+ await client.request(deleteItem('navigation', id));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
+ toast.success('Menu item deleted');
+ }
+ });
+
+ const updateSortMutation = useMutation({
+ mutationFn: async ({ id, sort }: { id: string, sort: number }) => {
+ // @ts-ignore
+ await client.request(updateItem('navigation', id, { sort }));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
+ }
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ Label
+ URL
+ Actions
+
+
+
+ {items.length === 0 ? (
+
+
+ No menu items. Add one above.
+
+
+ ) : (
+ items.map((item, index) => (
+
+
+
+
+
+ {item.label}
+
+
+ {item.url}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/sites/PageEditor.tsx b/frontend/src/components/admin/sites/PageEditor.tsx
new file mode 100644
index 0000000..5f39d3f
--- /dev/null
+++ b/frontend/src/components/admin/sites/PageEditor.tsx
@@ -0,0 +1,257 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { getDirectusClient, readItem, updateItem } from '@/lib/directus/client';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Card, CardContent } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { GripVertical, Plus, Trash2, LayoutTemplate, Type, Image as ImageIcon, Save, ArrowLeft } from 'lucide-react';
+import { toast } from 'sonner';
+
+const client = getDirectusClient();
+
+interface PageBlock {
+ id: string;
+ type: 'hero' | 'content' | 'features' | 'cta';
+ data: any;
+}
+
+interface Page {
+ id: string;
+ title: string;
+ permalink: string;
+ status: string;
+ blocks: PageBlock[];
+}
+
+interface PageEditorProps {
+ pageId: string;
+ onBack?: () => void;
+}
+
+export default function PageEditor({ pageId, onBack }: PageEditorProps) {
+ const queryClient = useQueryClient();
+ const [blocks, setBlocks] = useState([]);
+ const [pageMeta, setPageMeta] = useState>({});
+
+ const { isLoading } = useQuery({
+ queryKey: ['page', pageId],
+ queryFn: async () => {
+ // @ts-ignore
+ const res = await client.request(readItem('pages', pageId));
+ const page = res as unknown as Page;
+ setBlocks(page.blocks || []);
+ setPageMeta({ title: page.title, permalink: page.permalink, status: page.status });
+ return page;
+ },
+ enabled: !!pageId
+ });
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ // @ts-ignore
+ await client.request(updateItem('pages', pageId, {
+ ...pageMeta,
+ blocks: blocks
+ }));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['page', pageId] });
+ toast.success('Page saved successfully');
+ }
+ });
+
+ const addBlock = (type: PageBlock['type']) => {
+ const newBlock: PageBlock = {
+ id: crypto.randomUUID(),
+ type,
+ data: type === 'hero' ? { title: 'New Hero', subtitle: 'Subtitle here', bg: 'default' } :
+ type === 'content' ? { content: 'Start writing...
' } :
+ type === 'features' ? { items: [{ title: 'Feature 1', desc: 'Description' }] } :
+ { label: 'Click Me', url: '#' }
+ };
+ setBlocks([...blocks, newBlock]);
+ };
+
+ const updateBlock = (id: string, data: any) => {
+ setBlocks(blocks.map(b => b.id === id ? { ...b, data: { ...b.data, ...data } } : b));
+ };
+
+ const removeBlock = (id: string) => {
+ setBlocks(blocks.filter(b => b.id !== id));
+ };
+
+ const moveBlock = (index: number, direction: 'up' | 'down') => {
+ const newBlocks = [...blocks];
+ if (direction === 'up' && index > 0) {
+ [newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
+ } else if (direction === 'down' && index < newBlocks.length - 1) {
+ [newBlocks[index + 1], newBlocks[index]] = [newBlocks[index], newBlocks[index + 1]];
+ }
+ setBlocks(newBlocks);
+ };
+
+ if (isLoading) return Loading editor...
;
+
+ return (
+
+ {/* Sidebar Controls */}
+
+
+ {onBack &&
}
+
+
Page Editor
+ setPageMeta({ ...pageMeta, title: e.target.value })}
+ className="h-7 text-xs bg-transparent border-0 px-0 focus-visible:ring-0 placeholder:text-zinc-600"
+ placeholder="Page Title"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setPageMeta({ ...pageMeta, permalink: e.target.value })}
+ className="bg-zinc-950 border-zinc-800 h-8"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Visual Canvas (Preview + Edit) */}
+
+
+ {blocks.map((block, index) => (
+
+ {/* Block Actions */}
+
+
+
+
+
+
+
+ {/* Type Label */}
+ {block.type}
+
+ {/* HERO EDITOR */}
+ {block.type === 'hero' && (
+
+ updateBlock(block.id, { title: e.target.value })}
+ className="text-4xl font-bold bg-transparent border-0 text-center placeholder:text-zinc-700 h-auto focus-visible:ring-0 p-0"
+ placeholder="Hero Headline"
+ />
+ updateBlock(block.id, { subtitle: e.target.value })}
+ className="text-xl text-zinc-400 bg-transparent border-0 text-center placeholder:text-zinc-700 h-auto focus-visible:ring-0 p-0"
+ placeholder="Hero Subtitle"
+ />
+
+ )}
+
+ {/* CONTENT EDITOR */}
+ {block.type === 'content' && (
+
+
+ )}
+
+ {/* FEATURES EDITOR */}
+ {block.type === 'features' && (
+
+ {(block.data.items || []).map((item: any, i: number) => (
+
+ {
+ const newItems = [...block.data.items];
+ newItems[i].title = e.target.value;
+ updateBlock(block.id, { items: newItems });
+ }}
+ className="font-bold bg-transparent border-0 p-0 h-auto focus-visible:ring-0"
+ />
+
+ ))}
+
+
+ )}
+
+
+ ))}
+
+ {blocks.length === 0 && (
+
+
+
Page is empty.
+
Use the sidebar to add blocks.
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/sites/SiteDashboard.tsx b/frontend/src/components/admin/sites/SiteDashboard.tsx
new file mode 100644
index 0000000..d1ad94e
--- /dev/null
+++ b/frontend/src/components/admin/sites/SiteDashboard.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import SitePagesManager from './SitePagesManager';
+import NavigationManager from './NavigationManager';
+import ThemeSettings from './ThemeSettings';
+import { Card } from '@/components/ui/card';
+
+interface SiteDashboardProps {
+ siteId: string;
+}
+
+export default function SiteDashboard({ siteId }: SiteDashboardProps) {
+ return (
+
+
+ Pages
+ Navigation
+ Appearance
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Advanced site settings coming soon in Milestone 5.
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/sites/SitePagesManager.tsx b/frontend/src/components/admin/sites/SitePagesManager.tsx
new file mode 100644
index 0000000..6c82d00
--- /dev/null
+++ b/frontend/src/components/admin/sites/SitePagesManager.tsx
@@ -0,0 +1,166 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { getDirectusClient, readItems, createItem, deleteItem } from '@/lib/directus/client';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { FileText, Plus, Trash2, Edit, ExternalLink } from 'lucide-react';
+import { toast } from 'sonner';
+
+const client = getDirectusClient();
+
+interface Page {
+ id: string;
+ title: string;
+ permalink: string;
+ status: string;
+ date_updated: string;
+}
+
+interface SitePagesManagerProps {
+ siteId: string;
+ siteDomain: string;
+}
+
+export default function SitePagesManager({ siteId, siteDomain }: SitePagesManagerProps) {
+ const queryClient = useQueryClient();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [newPageTitle, setNewPageTitle] = useState('');
+
+ const { data: pages = [], isLoading } = useQuery({
+ queryKey: ['pages', siteId],
+ queryFn: async () => {
+ // @ts-ignore
+ const res = await client.request(readItems('pages', {
+ filter: { site: { _eq: siteId } },
+ sort: ['permalink']
+ }));
+ return res as unknown as Page[];
+ }
+ });
+
+ const createMutation = useMutation({
+ mutationFn: async () => {
+ // @ts-ignore
+ const res = await client.request(createItem('pages', {
+ title: newPageTitle,
+ site: siteId, // UUID usually
+ permalink: `/${newPageTitle.toLowerCase().replace(/ /g, '-')}`,
+ status: 'draft',
+ blocks: []
+ }));
+ return res;
+ },
+ onSuccess: (data: any) => {
+ queryClient.invalidateQueries({ queryKey: ['pages', siteId] });
+ toast.success('Page created');
+ setCreateOpen(false);
+ setNewPageTitle('');
+ // Redirect to editor
+ window.location.href = `/admin/sites/editor/${data.id}`;
+ },
+ onError: (e: any) => {
+ toast.error('Failed to create page: ' + e.message);
+ }
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: string) => {
+ // @ts-ignore
+ await client.request(deleteItem('pages', id));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['pages', siteId] });
+ toast.success('Page deleted');
+ }
+ });
+
+ return (
+
+
+
+ Pages
+
+
+
+
+
+
+
+
+ Title
+ Permalink
+ Status
+ Actions
+
+
+
+ {pages.length === 0 ? (
+
+
+ No pages yet.
+
+
+ ) : (
+ pages.map((page) => (
+
+
+ {page.title}
+
+
+ {page.permalink}
+
+
+
+ {page.status}
+
+
+
+
+
+
+ {page.status === 'published' && (
+
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/sites/SitesManager.tsx b/frontend/src/components/admin/sites/SitesManager.tsx
index e69de29..9af30ea 100644
--- a/frontend/src/components/admin/sites/SitesManager.tsx
+++ b/frontend/src/components/admin/sites/SitesManager.tsx
@@ -0,0 +1,173 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger
+} from '@/components/ui/dialog';
+import { Globe, Plus, Settings, Trash2, ExternalLink } from 'lucide-react';
+import { toast } from 'sonner';
+
+const client = getDirectusClient();
+
+interface Site {
+ id: string;
+ name: string;
+ domain: string;
+ status: 'active' | 'inactive';
+ settings?: any;
+}
+
+export default function SitesManager() {
+ const queryClient = useQueryClient();
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editingSite, setEditingSite] = useState>({});
+
+ // Fetch
+ const { data: sites = [], isLoading } = useQuery({
+ queryKey: ['sites'],
+ queryFn: async () => {
+ // @ts-ignore
+ const res = await client.request(readItems('sites', { limit: -1 }));
+ return res as unknown as Site[];
+ }
+ });
+
+ // Mutations
+ const mutation = useMutation({
+ mutationFn: async (site: Partial) => {
+ if (site.id) {
+ // @ts-ignore
+ await client.request(updateItem('sites', site.id, site));
+ } else {
+ // @ts-ignore
+ await client.request(createItem('sites', site));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['sites'] });
+ toast.success(editingSite.id ? 'Site updated' : 'Site created');
+ setEditorOpen(false);
+ }
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: string) => {
+ // @ts-ignore
+ await client.request(deleteItem('sites', id));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['sites'] });
+ toast.success('Site deleted');
+ }
+ });
+
+ return (
+
+
+
+
Your Sites
+
Manage your deployed web properties.
+
+
+
+
+
+ {sites.map((site) => (
+
+
+
+ {site.name}
+
+
+ {site.status || 'inactive'}
+
+
+
+ {site.domain}
+
+
+ deployed via Launchpad
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {sites.length === 0 && (
+
+
+
No sites configured yet.
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/sites/ThemeSettings.tsx b/frontend/src/components/admin/sites/ThemeSettings.tsx
new file mode 100644
index 0000000..5451f34
--- /dev/null
+++ b/frontend/src/components/admin/sites/ThemeSettings.tsx
@@ -0,0 +1,116 @@
+import React, { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Save, Palette, Type } from 'lucide-react';
+import { toast } from 'sonner';
+
+const client = getDirectusClient();
+
+interface GlobalSettings {
+ id?: string;
+ site: string;
+ primary_color: string;
+ secondary_color: string;
+ footer_text: string;
+}
+
+interface ThemeSettingsProps {
+ siteId: string;
+}
+
+export default function ThemeSettings({ siteId }: ThemeSettingsProps) {
+ const queryClient = useQueryClient();
+ const [settings, setSettings] = useState>({});
+
+ const { data: globalRecord, isLoading } = useQuery({
+ queryKey: ['globals', siteId],
+ queryFn: async () => {
+ // @ts-ignore
+ const res = await client.request(readItems('globals', {
+ filter: { site: { _eq: siteId } },
+ limit: 1
+ }));
+ const record = res[0] as GlobalSettings;
+ if (record) setSettings(record);
+ return record;
+ }
+ });
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ if (globalRecord?.id) {
+ // @ts-ignore
+ await client.request(updateItem('globals', globalRecord.id, settings));
+ } else {
+ // @ts-ignore
+ await client.request(createItem('globals', { ...settings, site: siteId }));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['globals', siteId] });
+ toast.success('Theme settings saved');
+ }
+ });
+
+ if (isLoading) return Loading settings...
;
+
+ return (
+
+
+
+ Colors
+
+
+
+
+
+
+
setSettings({ ...settings, primary_color: e.target.value })}
+ placeholder="#000000"
+ className="bg-zinc-950 border-zinc-800 font-mono"
+ />
+
+
+
+
+
+
+
setSettings({ ...settings, secondary_color: e.target.value })}
+ placeholder="#ffffff"
+ className="bg-zinc-950 border-zinc-800 font-mono"
+ />
+
+
+
+
+
+
+
+ Typography & Text
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/sites/[siteId]/index.astro b/frontend/src/pages/admin/sites/[siteId]/index.astro
new file mode 100644
index 0000000..85a4b59
--- /dev/null
+++ b/frontend/src/pages/admin/sites/[siteId]/index.astro
@@ -0,0 +1,28 @@
+---
+import Layout from '@/layouts/AdminLayout.astro';
+import SiteDashboard from '@/components/admin/sites/SiteDashboard';
+import { Button } from '@/components/ui/button';
+import { ArrowLeft } from 'lucide-react';
+
+const { siteId } = Astro.params;
+---
+
+
+
+
+
+
+
+
+
Site Management
+
ID: {siteId}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/admin/sites/editor/[pageId].astro b/frontend/src/pages/admin/sites/editor/[pageId].astro
new file mode 100644
index 0000000..1dc2adb
--- /dev/null
+++ b/frontend/src/pages/admin/sites/editor/[pageId].astro
@@ -0,0 +1,10 @@
+---
+import Layout from '@/layouts/AdminLayout.astro';
+import PageEditor from '@/components/admin/sites/PageEditor';
+
+const { pageId } = Astro.params;
+---
+
+
+ history.back()} />
+
diff --git a/frontend/src/pages/admin/sites/index.astro b/frontend/src/pages/admin/sites/index.astro
index f123638..7028cda 100644
--- a/frontend/src/pages/admin/sites/index.astro
+++ b/frontend/src/pages/admin/sites/index.astro
@@ -1,27 +1,19 @@
---
import Layout from '@/layouts/AdminLayout.astro';
-import SiteList from '@/components/admin/sites/SiteList';
+import SitesManager from '@/components/admin/sites/SitesManager';
---
-
-
-
+
+
+
-
My Sites
-
Manage your connected WordPress and Webflow sites.
-
-
-
+