diff --git a/apps/api/src/db/organization_config.py b/apps/api/src/db/organization_config.py index a54e537e..5812179e 100644 --- a/apps/api/src/db/organization_config.py +++ b/apps/api/src/db/organization_config.py @@ -82,6 +82,7 @@ class OrgGeneralConfig(BaseModel): color: str = "normal" watermark: bool = True + # Cloud class OrgCloudConfig(BaseModel): plan: Literal["free", "standard", "pro"] = "free" @@ -90,10 +91,11 @@ class OrgCloudConfig(BaseModel): # Main Config class OrganizationConfigBase(BaseModel): - config_version: str = "1.2" + config_version: str = "1.3" general: OrgGeneralConfig features: OrgFeatureConfig cloud: OrgCloudConfig + landing: dict = {} class OrganizationConfig(SQLModel, table=True): diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index e6025829..737bf2ce 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -40,6 +40,8 @@ from src.services.orgs.orgs import ( update_org_preview, update_org_signup_mechanism, update_org_thumbnail, + update_org_landing, + upload_org_landing_content_service, ) @@ -413,3 +415,37 @@ async def api_delete_org( """ return await delete_org(request, org_id, current_user, db_session) + + +@router.put("/{org_id}/landing") +async def api_update_org_landing( + request: Request, + org_id: int, + landing_object: dict, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Update organization landing object + """ + return await update_org_landing(request, landing_object, org_id, current_user, db_session) + + +@router.post("/{org_id}/landing/content") +async def api_upload_org_landing_content( + request: Request, + org_id: int, + content_file: UploadFile, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Upload content for organization landing page + """ + return await upload_org_landing_content_service( + request=request, + content_file=content_file, + org_id=org_id, + current_user=current_user, + db_session=db_session, + ) diff --git a/apps/api/src/services/install/install.py b/apps/api/src/services/install/install.py index 84841fb4..aea4551e 100644 --- a/apps/api/src/services/install/install.py +++ b/apps/api/src/services/install/install.py @@ -330,7 +330,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess # Org Config org_config = OrganizationConfigBase( - config_version="1.2", + config_version="1.3", general=OrgGeneralConfig( enabled=True, color="normal", @@ -354,7 +354,8 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess cloud=OrgCloudConfig( plan='free', custom_domain=False - ) + ), + landing={} ) org_config = json.loads(org_config.json()) diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 447e26b1..9f580b28 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -36,7 +36,7 @@ from src.db.organizations import ( ) from fastapi import HTTPException, UploadFile, status, Request -from src.services.orgs.uploads import upload_org_logo, upload_org_preview, upload_org_thumbnail +from src.services.orgs.uploads import upload_org_logo, upload_org_preview, upload_org_thumbnail, upload_org_landing_content async def get_organization( @@ -714,6 +714,86 @@ async def upload_org_preview_service( "filename": name_in_disk } +async def update_org_landing( + request: Request, + landing_object: dict, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Get org config + statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id) + result = db_session.exec(statement) + + org_config = result.first() + + if org_config is None: + logging.error(f"Organization {org_id} has no config") + raise HTTPException( + status_code=404, + detail="Organization config not found", + ) + + # Convert to OrganizationConfigBase model and back to ensure all fields exist + config_model = OrganizationConfigBase(**org_config.config) + + # Update the landing object + config_model.landing = landing_object + + # Convert back to dict and update + updated_config = json.loads(config_model.json()) + org_config.config = updated_config + org_config.update_date = str(datetime.now()) + + db_session.add(org_config) + db_session.commit() + db_session.refresh(org_config) + + return {"detail": "Landing object updated"} + +async def upload_org_landing_content_service( + request: Request, + content_file: UploadFile, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> dict: + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Upload content + name_in_disk = await upload_org_landing_content(content_file, org.org_uuid) + + return { + "detail": "Landing content uploaded successfully", + "filename": name_in_disk + } + ## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/orgs/uploads.py b/apps/api/src/services/orgs/uploads.py index 7c393d53..83296fd7 100644 --- a/apps/api/src/services/orgs/uploads.py +++ b/apps/api/src/services/orgs/uploads.py @@ -1,4 +1,6 @@ from uuid import uuid4 +from fastapi import UploadFile +from fastapi import HTTPException from src.services.utils.upload_content import upload_content @@ -45,4 +47,23 @@ async def upload_org_preview(file, org_uuid: str) -> str: name_in_disk, ) + return name_in_disk + + +async def upload_org_landing_content(file: UploadFile, org_uuid: str) -> str: + if not file or not file.filename: + raise HTTPException(status_code=400, detail="No file provided or invalid filename") + + contents = file.file.read() + name_in_disk = f"{uuid4()}.{file.filename.split('.')[-1]}" + + await upload_content( + "landing", + "orgs", + org_uuid, + contents, + name_in_disk, + ["jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "pdf"] # Common web content formats + ) + return name_in_disk \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index 178e4e04..adec92ed 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -18,6 +18,7 @@ import CourseUpdates from '@components/Objects/Courses/CourseUpdates/CourseUpdat import { CourseProvider } from '@components/Contexts/CourseContext' import { useMediaQuery } from 'usehooks-ts' import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions' +import CourseActionsMobile from '@components/Objects/Courses/CourseActions/CourseActionsMobile' const CourseClient = (props: any) => { const [learnings, setLearnings] = useState([]) @@ -65,265 +66,273 @@ const CourseClient = (props: any) => { {!course && !org ? ( ) : ( - -
-
-

Course

-

{course.name}

-
-
- {!isMobile && - - } -
-
- - {props.course?.thumbnail_image && org ? ( -
- ) : ( -
- )} - - - -
-
-

About

-
-

{course.about}

+ <> + +
+
+

Course

+

{course.name}

+
+ {!isMobile && + + } +
+
- {learnings.length > 0 && learnings[0]?.text !== 'null' && ( -
-

- What you will learn -

-
- {learnings.map((learning: any) => { - // Handle both new format (object with text and emoji) and legacy format (string) - const learningText = typeof learning === 'string' ? learning : learning.text - const learningEmoji = typeof learning === 'string' ? null : learning.emoji - const learningId = typeof learning === 'string' ? learning : learning.id || learning.text - - if (!learningText) return null - - return ( -
-
- {learningEmoji ? ( - {learningEmoji} - ) : ( - + {props.course?.thumbnail_image && org ? ( +
+ ) : ( +
+ )} + + + +
+
+

About

+
+

{course.about}

+
+ + {learnings.length > 0 && learnings[0]?.text !== 'null' && ( +
+

+ What you will learn +

+
+ {learnings.map((learning: any) => { + // Handle both new format (object with text and emoji) and legacy format (string) + const learningText = typeof learning === 'string' ? learning : learning.text + const learningEmoji = typeof learning === 'string' ? null : learning.emoji + const learningId = typeof learning === 'string' ? learning : learning.id || learning.text + + if (!learningText) return null + + return ( +
+
+ {learningEmoji ? ( + {learningEmoji} + ) : ( + + )} +
+

{learningText}

+ {learning.link && ( + + Link to {learningText} + + )}
-

{learningText}

- {learning.link && ( - - Link to {learningText} - - - )} -
- ) - })} -
-
- )} - -

Course Lessons

-
- {course.chapters.map((chapter: any) => { - return ( -
-
-

{chapter.name}

-

- {chapter.activities.length} Activities -

-
-
- {chapter.activities.map((activity: any) => { - return ( - <> -

-
-
- {activity.activity_type === - 'TYPE_DYNAMIC' && ( -
- -
- )} - {activity.activity_type === 'TYPE_VIDEO' && ( -
-
- )} - {activity.activity_type === - 'TYPE_DOCUMENT' && ( -
- -
- )} - {activity.activity_type === - 'TYPE_ASSIGNMENT' && ( -
- -
- )} -
- -

{activity.name}

- -
- {activity.activity_type === - 'TYPE_DYNAMIC' && ( - <> - -
-

Page

- -
- - - )} - {activity.activity_type === 'TYPE_VIDEO' && ( - <> - -
-

Video

- -
- - - )} - {activity.activity_type === - 'TYPE_DOCUMENT' && ( - <> - -
-

Document

- -
- - - )} - {activity.activity_type === - 'TYPE_ASSIGNMENT' && ( - <> - -
-

Assignment

- -
- - - )} -
-
- - ) - })} -
+ ) + })}
- ) - })} +
+ )} + +

Course Lessons

+
+ {course.chapters.map((chapter: any) => { + return ( +
+
+

{chapter.name}

+

+ {chapter.activities.length} Activities +

+
+
+ {chapter.activities.map((activity: any) => { + return ( + <> +

+
+
+ {activity.activity_type === + 'TYPE_DYNAMIC' && ( +
+ +
+ )} + {activity.activity_type === 'TYPE_VIDEO' && ( +
+
+ )} + {activity.activity_type === + 'TYPE_DOCUMENT' && ( +
+ +
+ )} + {activity.activity_type === + 'TYPE_ASSIGNMENT' && ( +
+ +
+ )} +
+ +

{activity.name}

+ +
+ {activity.activity_type === + 'TYPE_DYNAMIC' && ( + <> + +
+

Page

+ +
+ + + )} + {activity.activity_type === 'TYPE_VIDEO' && ( + <> + +
+

Video

+ +
+ + + )} + {activity.activity_type === + 'TYPE_DOCUMENT' && ( + <> + +
+

Document

+ +
+ + + )} + {activity.activity_type === + 'TYPE_ASSIGNMENT' && ( + <> + +
+

Assignment

+ +
+ + + )} +
+
+ + ) + })} +
+
+ ) + })} +
+
+
+
-
- + + + {isMobile && ( +
+
-
- + )} + )} ) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx index 294614ce..ef1780aa 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx @@ -16,6 +16,8 @@ import { getOrgCollections } from '@services/courses/collections' import { getServerSession } from 'next-auth' import { nextAuthOptions } from 'app/auth/options' import { getOrgThumbnailMediaDirectory } from '@services/media/media' +import LandingClassic from '@components/Landings/LandingClassic' +import LandingCustom from '@components/Landings/LandingCustom' type MetadataProps = { params: { orgslug: string } @@ -71,7 +73,7 @@ const OrgHomePage = async (params: any) => { access_token ? access_token : null ) const org = await getOrganizationContextInfo(orgslug, { - revalidate: 1800, + revalidate: 0, tags: ['organizations'], }) const org_id = org.id @@ -81,141 +83,24 @@ const OrgHomePage = async (params: any) => { { revalidate: 0, tags: ['courses'] } ) + // Check if custom landing is enabled + const hasCustomLanding = org.config?.config?.landing?.enabled + return (
- - {/* Collections */} -
-
- - - - - - -
-
- {collections.map((collection: any) => ( -
- -
- ))} - {collections.length === 0 && ( -
-
-
- - - - -
-

- No collections yet -

-

- -

-
-
- )} -
-
- - {/* Courses */} -
-
- - - - - - -
-
- {courses.map((course: any) => ( -
- -
- ))} - {courses.length === 0 && ( -
-
-
- - - - -
-

- No courses yet -

-

- -

-
-
- )} -
-
-
+ {hasCustomLanding ? ( + + ) : ( + + )}
) } diff --git a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx index 44a886d5..481cd66c 100644 --- a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx @@ -1,13 +1,14 @@ 'use client' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { getUriWithOrg } from '@services/config/config' -import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon } from 'lucide-react' +import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon, LayoutDashboardIcon } from 'lucide-react' import Link from 'next/link' import React, { useEffect } from 'react' import { motion } from 'framer-motion' import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral' import OrgEditImages from '@components/Dashboard/Pages/Org/OrgEditImages/OrgEditImages' import OrgEditSocials from '@components/Dashboard/Pages/Org/OrgEditSocials/OrgEditSocials' +import OrgEditLanding from '@components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding' export type OrgParams = { subpage: string @@ -22,6 +23,7 @@ interface TabItem { const SETTING_TABS: TabItem[] = [ { id: 'general', label: 'General', icon: TextIcon }, + { id: 'landing', label: 'Landing Page', icon: LayoutDashboardIcon }, { id: 'previews', label: 'Images & Previews', icon: ImageIcon }, { id: 'socials', label: 'Socials', icon: Share2Icon }, ] @@ -61,6 +63,9 @@ function OrgPage({ params }: { params: OrgParams }) { } else if (params.subpage == 'socials') { setH1Label('Socials') setH2Label('Manage your organization social media links') + } else if (params.subpage == 'landing') { + setH1Label('Landing Page') + setH2Label('Customize your organization landing page') } } @@ -103,6 +108,7 @@ function OrgPage({ params }: { params: OrgParams }) { {params.subpage == 'general' ? : ''} {params.subpage == 'previews' ? : ''} {params.subpage == 'socials' ? : ''} + {params.subpage == 'landing' ? : ''}
) diff --git a/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx new file mode 100644 index 00000000..8e9d6b53 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx @@ -0,0 +1,1588 @@ +'use client' +import React from 'react' +import { LandingObject, LandingSection, LandingHeroSection, LandingTextAndImageSection, LandingLogos, LandingPeople, LandingBackground, LandingButton, LandingHeading, LandingImage, LandingFeaturedCourses } from './landing_types' +import { Plus, Eye, ArrowUpDown, Trash2, GripVertical, LayoutTemplate, ImageIcon, Users, Award, ArrowRight, Edit, Link, Upload, Save, BookOpen, TextIcon } from 'lucide-react' +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' +import { Input } from "@components/ui/input" +import { Textarea } from "@components/ui/textarea" +import { Label } from "@components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select" +import { Button } from "@components/ui/button" +import { useOrg } from '@components/Contexts/OrgContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { updateOrgLanding, uploadLandingContent } from '@services/organizations/orgs' +import { getOrgLandingMediaDirectory } from '@services/media/media' +import { getOrgCourses } from '@services/courses/courses' +import toast from 'react-hot-toast' +import useSWR from 'swr' +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@components/ui/tabs" + +const SECTION_TYPES = { + hero: { + icon: LayoutTemplate, + label: 'Hero', + description: 'Add a hero section with heading and call-to-action' + }, + 'text-and-image': { + icon: ImageIcon, + label: 'Text & Image', + description: 'Add a section with text and an image' + }, + logos: { + icon: Award, + label: 'Logos', + description: 'Add a section to showcase logos' + }, + people: { + icon: Users, + label: 'People', + description: 'Add a section to highlight team members' + }, + 'featured-courses': { + icon: BookOpen, + label: 'Courses', + description: 'Add a section to showcase selected courses' + } +} as const + +const PREDEFINED_GRADIENTS = { + 'sunrise': { + colors: ['#fef9f3', '#ffecd2'] as Array, + direction: '45deg' + }, + 'mint-breeze': { + colors: ['#f0fff4', '#dcfce7'] as Array, + direction: '45deg' + }, + 'deep-ocean': { + colors: ['#0f172a', '#1e3a8a'] as Array, + direction: '135deg' + }, + 'sunset-blaze': { + colors: ['#7f1d1d', '#ea580c'] as Array, + direction: '45deg' + }, + 'midnight-purple': { + colors: ['#581c87', '#7e22ce'] as Array, + direction: '90deg' + }, + 'forest-depths': { + colors: ['#064e3b', '#059669'] as Array, + direction: '225deg' + }, + 'berry-fusion': { + colors: ['#831843', '#be185d'] as Array, + direction: '135deg' + }, + 'cosmic-night': { + colors: ['#1e1b4b', '#4338ca'] as Array, + direction: '45deg' + }, + 'autumn-fire': { + colors: ['#7c2d12', '#c2410c'] as Array, + direction: '90deg' + }, + 'emerald-depths': { + colors: ['#064e3b', '#10b981'] as Array, + direction: '135deg' + }, + 'royal-navy': { + colors: ['#1e3a8a', '#3b82f6'] as Array, + direction: '225deg' + }, + 'volcanic': { + colors: ['#991b1b', '#f97316'] as Array, + direction: '315deg' + }, + 'arctic-night': { + colors: ['#0f172a', '#475569'] as Array, + direction: '90deg' + }, + 'grape-punch': { + colors: ['#6b21a8', '#d946ef'] as Array, + direction: '135deg' + }, + 'marine-blue': { + colors: ['#0c4a6e', '#0ea5e9'] as Array, + direction: '45deg' + } +} as const + +const GRADIENT_DIRECTIONS = { + '45deg': '↗️ Top Right', + '90deg': '⬆️ Top', + '135deg': '↖️ Top Left', + '180deg': '⬅️ Left', + '225deg': '↙️ Bottom Left', + '270deg': '⬇️ Bottom', + '315deg': '↘️ Bottom Right', + '0deg': '➡️ Right' +} as const + +const getSectionDisplayName = (section: LandingSection) => { + return SECTION_TYPES[section.type as keyof typeof SECTION_TYPES].label +} + +const OrgEditLanding = () => { + const org = useOrg() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + const [isLandingEnabled, setIsLandingEnabled] = React.useState(false) + const [landingData, setLandingData] = React.useState({ + sections: [], + enabled: false + }) + const [selectedSection, setSelectedSection] = React.useState(null) + const [isSaving, setIsSaving] = React.useState(false) + + // Initialize landing data from org config + React.useEffect(() => { + if (org?.config?.config?.landing) { + const landingConfig = org.config.config.landing + setLandingData({ + sections: landingConfig.sections || [], + enabled: landingConfig.enabled || false + }) + setIsLandingEnabled(landingConfig.enabled || false) + } + }, [org]) + + const addSection = (type: string) => { + const newSection: LandingSection = createEmptySection(type) + setLandingData(prev => ({ + ...prev, + sections: [...prev.sections, newSection] + })) + } + + const createEmptySection = (type: string): LandingSection => { + switch (type) { + case 'hero': + return { + type: 'hero', + title: 'New Hero Section', + background: { + type: 'solid', + color: '#ffffff' + }, + heading: { + text: 'Welcome', + color: '#000000', + size: 'large' + }, + subheading: { + text: 'Start your learning journey', + color: '#666666', + size: 'medium' + }, + buttons: [], + illustration: undefined, + contentAlign: 'center' + } + case 'text-and-image': + return { + type: 'text-and-image', + title: 'New Text & Image Section', + text: 'Add your content here', + flow: 'left', + image: { + url: '', + alt: '' + }, + buttons: [] + } + case 'logos': + return { + type: 'logos', + title: 'New Logos Section', + logos: [] + } + case 'people': + return { + type: 'people', + title: 'New People Section', + people: [] + } + case 'featured-courses': + return { + type: 'featured-courses', + title: 'Courses', + courses: [] + } + default: + throw new Error('Invalid section type') + } + } + + const updateSection = (index: number, updatedSection: LandingSection) => { + const newSections = [...landingData.sections] + newSections[index] = updatedSection + setLandingData(prev => ({ + ...prev, + sections: newSections + })) + } + + const deleteSection = (index: number) => { + setLandingData(prev => ({ + ...prev, + sections: prev.sections.filter((_, i) => i !== index) + })) + setSelectedSection(null) + } + + const onDragEnd = (result: any) => { + if (!result.destination) return + + const items = Array.from(landingData.sections) + const [reorderedItem] = items.splice(result.source.index, 1) + items.splice(result.destination.index, 0, reorderedItem) + + setLandingData(prev => ({ + ...prev, + sections: items + })) + setSelectedSection(result.destination.index) + } + + const handleSave = async () => { + if (!org?.id) { + toast.error('Organization ID not found') + return + } + + setIsSaving(true) + try { + const res = await updateOrgLanding(org.id, { + sections: landingData.sections, + enabled: isLandingEnabled + }, access_token) + + if (res.status === 200) { + toast.success('Landing page saved successfully') + } else { + toast.error('Error saving landing page') + } + } catch (error) { + toast.error('Error saving landing page') + console.error('Error saving landing page:', error) + } finally { + setIsSaving(false) + } + } + + return ( +
+
+ {/* Enable/Disable Landing Page */} +
+
+

Landing Page
BETA

+

Customize your organization's landing page

+
+
+ + +
+
+ + {isLandingEnabled && ( + <> + {/* Section List */} +
+ {/* Sections Panel */} +
+

Sections

+ + + {(provided) => ( +
+ {landingData.sections.map((section, index) => ( + + {(provided, snapshot) => ( +
setSelectedSection(index)} + className={`p-4 bg-white/80 backdrop-blur-sm rounded-lg cursor-pointer border ${ + selectedSection === index + ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20 shadow-sm' + : 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 hover:shadow-sm' + } ${snapshot.isDragging ? 'shadow-lg ring-2 ring-blue-500/20 rotate-2' : ''}`} + > +
+
+
+ +
+
+ {React.createElement(SECTION_TYPES[section.type as keyof typeof SECTION_TYPES].icon, { + size: 16 + })} +
+ + {getSectionDisplayName(section)} + +
+
+ + +
+
+
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ +
+ +
+
+ + {/* Editor Panel */} +
+ {selectedSection !== null ? ( + updateSection(selectedSection, updatedSection)} + /> + ) : ( +
+ Select a section to edit or add a new one +
+ )} +
+
+ + )} +
+
+ ) +} + +interface SectionEditorProps { + section: LandingSection + onChange: (section: LandingSection) => void +} + +const SectionEditor: React.FC = ({ section, onChange }) => { + switch (section.type) { + case 'hero': + return + case 'text-and-image': + return + case 'logos': + return + case 'people': + return + case 'featured-courses': + return + default: + return
Unknown section type
+ } +} + +const HeroSectionEditor: React.FC<{ + section: LandingHeroSection + onChange: (section: LandingHeroSection) => void +}> = ({ section, onChange }) => { + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + onChange({ + ...section, + background: { + type: 'image', + image: reader.result as string + } + }) + } + reader.readAsDataURL(file) + } + } + + return ( +
+
+ +

Hero Section

+
+ +
+ {/* Title */} +
+ + onChange({ ...section, title: e.target.value })} + placeholder="Enter section title" + /> +
+ + + + + + Content + + + + Background + + + + +
+ {section.background.image && ( +
+ Background preview +
+ )} +
+
+ )} + + + +
+ {section.buttons.map((button, index) => ( +
+
+ + { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, text: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + placeholder="Button text" + /> +
+
+ + { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, color: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + className="w-full h-8 p-1" + /> +
+
+ + { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, background: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + className="w-full h-8 p-1" + /> +
+
+
+
+ +
+ + { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, link: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + placeholder="Button link" + /> +
+
+ +
+ ))} + {section.buttons.length < 2 && ( + + )} +
+
+ + +
+
+ + { + if (e.target.value) { + onChange({ + ...section, + illustration: { + image: { url: e.target.value, alt: section.illustration?.image.alt || '' }, + position: 'left', + verticalAlign: 'center', + size: 'medium' + } + }) + } + }} + placeholder="Illustration URL" + /> + { + if (section.illustration?.image.url) { + onChange({ + ...section, + illustration: { + ...section.illustration, + image: { ...section.illustration.image, alt: e.target.value } + } + }) + } + }} + placeholder="Alt text" + /> + onChange({ + ...section, + illustration: { + image: { url, alt: section.illustration?.image.alt || '' }, + position: 'left', + verticalAlign: 'center', + size: 'medium' + } + })} + buttonText="Upload Illustration" + /> + {section.illustration?.image.url && ( + {section.illustration?.image.alt} + )} +
+ +
+
+ + +
+ +
+ + +
+
+ + {section.illustration?.image.url && ( + + )} +
+
+ +
+
+ ) +} + +interface ImageUploaderProps { + onImageUploaded: (imageUrl: string) => void + className?: string + buttonText?: string + id: string +} + +const ImageUploader: React.FC = ({ onImageUploaded, className, buttonText = "Upload Image", id }) => { + const org = useOrg() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + const [isUploading, setIsUploading] = React.useState(false) + const inputId = `imageUpload-${id}` + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setIsUploading(true) + try { + const response = await uploadLandingContent(org.id, file, access_token) + if (response.status === 200) { + const imageUrl = getOrgLandingMediaDirectory(org.org_uuid, response.data.filename) + onImageUploaded(imageUrl) + toast.success('Image uploaded successfully') + } else { + toast.error('Failed to upload image') + } + } catch (error) { + console.error('Error uploading image:', error) + toast.error('Failed to upload image') + } finally { + setIsUploading(false) + } + } + + return ( +
+ + +
+ ) +} + +const TextAndImageSectionEditor: React.FC<{ + section: LandingTextAndImageSection + onChange: (section: LandingTextAndImageSection) => void +}> = ({ section, onChange }) => { + return ( +
+
+ +

Text & Image Section

+
+ +
+ {/* Title */} +
+ + onChange({ ...section, title: e.target.value })} + placeholder="Enter section title" + /> +
+ + {/* Text */} +
+ +