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 ( +
+
+

Add Menu Item

+
+ setNewItem({ ...newItem, label: e.target.value })} + className="bg-zinc-950 border-zinc-800" + /> + setNewItem({ ...newItem, url: e.target.value })} + className="bg-zinc-950 border-zinc-800" + /> + +
+
+ +
+ + + + + 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' && ( +
+