feat: implement image uploading to landing page

This commit is contained in:
swve 2025-03-02 22:23:14 +01:00
parent d7b6e8282b
commit 3ce7dfdcd2
7 changed files with 244 additions and 37 deletions

View file

@ -41,6 +41,7 @@ from src.services.orgs.orgs import (
update_org_signup_mechanism, update_org_signup_mechanism,
update_org_thumbnail, update_org_thumbnail,
update_org_landing, update_org_landing,
upload_org_landing_content_service,
) )
@ -428,3 +429,23 @@ async def api_update_org_landing(
Update organization landing object Update organization landing object
""" """
return await update_org_landing(request, landing_object, org_id, current_user, db_session) 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,
)

View file

@ -36,7 +36,7 @@ from src.db.organizations import (
) )
from fastapi import HTTPException, UploadFile, status, Request 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( async def get_organization(
@ -765,6 +765,35 @@ async def update_org_landing(
return {"detail": "Landing object updated"} 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 ## ## 🔒 RBAC Utils ##

View file

@ -1,4 +1,6 @@
from uuid import uuid4 from uuid import uuid4
from fastapi import UploadFile
from fastapi import HTTPException
from src.services.utils.upload_content import upload_content from src.services.utils.upload_content import upload_content
@ -46,3 +48,22 @@ async def upload_org_preview(file, org_uuid: str) -> str:
) )
return 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

View file

@ -10,7 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Button } from "@components/ui/button" import { Button } from "@components/ui/button"
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { updateOrgLanding } from '@services/organizations/orgs' import { updateOrgLanding, uploadLandingContent } from '@services/organizations/orgs'
import { getOrgLandingMediaDirectory } from '@services/media/media'
import { getOrgCourses } from '@services/courses/courses' import { getOrgCourses } from '@services/courses/courses'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import useSWR from 'swr' import useSWR from 'swr'
@ -273,7 +274,7 @@ const OrgEditLanding = () => {
{/* Enable/Disable Landing Page */} {/* Enable/Disable Landing Page */}
<div className="flex items-center justify-between border-b pb-4"> <div className="flex items-center justify-between border-b pb-4">
<div> <div>
<h2 className="text-xl font-semibold">Landing Page</h2> <h2 className="text-xl font-semibold flex items-center">Landing Page <div className="text-xs ml-2 bg-gray-200 text-gray-700 px-2 py-1 rounded-full"> BETA </div></h2>
<p className="text-gray-600">Customize your organization's landing page</p> <p className="text-gray-600">Customize your organization's landing page</p>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@ -954,6 +955,64 @@ const HeroSectionEditor: React.FC<{
) )
} }
interface ImageUploaderProps {
onImageUploaded: (imageUrl: string) => void
className?: string
buttonText?: string
id: string
}
const ImageUploader: React.FC<ImageUploaderProps> = ({ 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<HTMLInputElement>) => {
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 (
<div className={className}>
<Button
variant="outline"
onClick={() => document.getElementById(inputId)?.click()}
disabled={isUploading}
className="w-full"
>
<Upload className="h-4 w-4 mr-2" />
{isUploading ? 'Uploading...' : buttonText}
</Button>
<input
id={inputId}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</div>
)
}
const TextAndImageSectionEditor: React.FC<{ const TextAndImageSectionEditor: React.FC<{
section: LandingTextAndImageSection section: LandingTextAndImageSection
onChange: (section: LandingTextAndImageSection) => void onChange: (section: LandingTextAndImageSection) => void
@ -1010,7 +1069,7 @@ const TextAndImageSectionEditor: React.FC<{
<div> <div>
<Label>Image</Label> <Label>Image</Label>
<div className="grid grid-cols-2 gap-4 mt-2"> <div className="grid grid-cols-2 gap-4 mt-2">
<div> <div className="space-y-2">
<Input <Input
value={section.image.url} value={section.image.url}
onChange={(e) => onChange({ onChange={(e) => onChange({
@ -1019,6 +1078,14 @@ const TextAndImageSectionEditor: React.FC<{
})} })}
placeholder="Image URL" placeholder="Image URL"
/> />
<ImageUploader
id="text-image-section"
onImageUploaded={(url) => onChange({
...section,
image: { ...section.image, url }
})}
buttonText="Upload New Image"
/>
</div> </div>
<div> <div>
<Input <Input
@ -1031,6 +1098,15 @@ const TextAndImageSectionEditor: React.FC<{
/> />
</div> </div>
</div> </div>
{section.image.url && (
<div className="mt-4">
<img
src={section.image.url}
alt={section.image.alt}
className="max-h-40 rounded-lg object-cover"
/>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -1064,6 +1140,7 @@ const LogosSectionEditor: React.FC<{
{section.logos.map((logo, index) => ( {section.logos.map((logo, index) => (
<div key={index} className="grid grid-cols-[1fr,1fr,auto] gap-2"> <div key={index} className="grid grid-cols-[1fr,1fr,auto] gap-2">
<div className="space-y-2">
<Input <Input
value={logo.url} value={logo.url}
onChange={(e) => { onChange={(e) => {
@ -1073,6 +1150,17 @@ const LogosSectionEditor: React.FC<{
}} }}
placeholder="Logo URL" placeholder="Logo URL"
/> />
<ImageUploader
id={`logo-${index}`}
onImageUploaded={(url) => {
const newLogos = [...section.logos]
newLogos[index] = { ...section.logos[index], url }
onChange({ ...section, logos: newLogos })
}}
buttonText="Upload Logo"
/>
</div>
<div className="space-y-2">
<Input <Input
value={logo.alt} value={logo.alt}
onChange={(e) => { onChange={(e) => {
@ -1082,6 +1170,14 @@ const LogosSectionEditor: React.FC<{
}} }}
placeholder="Alt text" placeholder="Alt text"
/> />
{logo.url && (
<img
src={logo.url}
alt={logo.alt}
className="h-10 object-contain"
/>
)}
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -1161,7 +1257,8 @@ const PeopleSectionEditor: React.FC<{
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Image URL</Label> <Label>Image</Label>
<div className="space-y-2">
<Input <Input
value={person.image_url} value={person.image_url}
onChange={(e) => { onChange={(e) => {
@ -1171,6 +1268,23 @@ const PeopleSectionEditor: React.FC<{
}} }}
placeholder="Image URL" placeholder="Image URL"
/> />
<ImageUploader
id={`person-${index}`}
onImageUploaded={(url) => {
const newPeople = [...section.people]
newPeople[index] = { ...section.people[index], image_url: url }
onChange({ ...section, people: newPeople })
}}
buttonText="Upload Avatar"
/>
{person.image_url && (
<img
src={person.image_url}
alt={person.name}
className="w-12 h-12 rounded-full object-cover"
/>
)}
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View file

@ -75,7 +75,7 @@ function LandingCustom({ landing, orgslug }: LandingCustomProps) {
return ( return (
<div <div
key={`text-image-${section.title}`} key={`text-image-${section.title}`}
className="mt-[20px] sm:mt-[40px] mx-2 sm:mx-4 lg:mx-16 w-full" className="py-16 mx-2 sm:mx-4 lg:mx-16 w-full"
> >
<div className={`flex flex-col md:flex-row items-center gap-8 md:gap-12 bg-white rounded-xl p-6 md:p-8 lg:p-12 nice-shadow ${ <div className={`flex flex-col md:flex-row items-center gap-8 md:gap-12 bg-white rounded-xl p-6 md:p-8 lg:p-12 nice-shadow ${
section.flow === 'right' ? 'md:flex-row-reverse' : '' section.flow === 'right' ? 'md:flex-row-reverse' : ''
@ -119,7 +119,7 @@ function LandingCustom({ landing, orgslug }: LandingCustomProps) {
return ( return (
<div <div
key={`logos-${section.type}`} key={`logos-${section.type}`}
className="mt-[20px] sm:mt-[40px] mx-2 sm:mx-4 lg:mx-16 w-full py-20" className="py-16 mx-2 sm:mx-4 lg:mx-16 w-full"
> >
{section.title && ( {section.title && (
<h2 className="text-2xl md:text-3xl font-bold text-left mb-16 text-gray-900">{section.title}</h2> <h2 className="text-2xl md:text-3xl font-bold text-left mb-16 text-gray-900">{section.title}</h2>
@ -143,7 +143,7 @@ function LandingCustom({ landing, orgslug }: LandingCustomProps) {
return ( return (
<div <div
key={`people-${section.title}`} key={`people-${section.title}`}
className="mt-[20px] sm:mt-[40px] mx-2 sm:mx-4 lg:mx-16 w-full py-16" className="py-16 mx-2 sm:mx-4 lg:mx-16 w-full"
> >
<h2 className="text-2xl md:text-3xl font-bold text-left mb-10 text-gray-900">{section.title}</h2> <h2 className="text-2xl md:text-3xl font-bold text-left mb-10 text-gray-900">{section.title}</h2>
<div className="flex flex-wrap justify-center gap-x-20 gap-y-8"> <div className="flex flex-wrap justify-center gap-x-20 gap-y-8">
@ -168,7 +168,7 @@ function LandingCustom({ landing, orgslug }: LandingCustomProps) {
return ( return (
<div <div
key={`featured-courses-${section.title}`} key={`featured-courses-${section.title}`}
className="mt-[20px] sm:mt-[40px] mx-2 sm:mx-4 lg:mx-16 w-full py-12" className="py-16 mx-2 sm:mx-4 lg:mx-16 w-full"
> >
<h2 className="text-2xl md:text-3xl font-bold text-left mb-6 text-gray-900">{section.title}</h2> <h2 className="text-2xl md:text-3xl font-bold text-left mb-6 text-gray-900">{section.title}</h2>
<div className="text-center py-6 text-gray-500">Loading courses...</div> <div className="text-center py-6 text-gray-500">Loading courses...</div>
@ -183,7 +183,7 @@ function LandingCustom({ landing, orgslug }: LandingCustomProps) {
return ( return (
<div <div
key={`featured-courses-${section.title}`} key={`featured-courses-${section.title}`}
className="mt-[20px] sm:mt-[40px] mx-2 sm:mx-4 lg:mx-16 w-full py-12" className="py-16 mx-2 sm:mx-4 lg:mx-16 w-full"
> >
<h2 className="text-2xl md:text-3xl font-bold text-left mb-6 text-gray-900">{section.title}</h2> <h2 className="text-2xl md:text-3xl font-bold text-left mb-6 text-gray-900">{section.title}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 sm:gap-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 sm:gap-6">

View file

@ -18,6 +18,11 @@ export function getCourseThumbnailMediaDirectory(
return uri return uri
} }
export function getOrgLandingMediaDirectory(orgUUID: string, fileId: string) {
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/landing/${fileId}`
return uri
}
export function getUserAvatarMediaDirectory(userUUID: string, fileId: string) { export function getUserAvatarMediaDirectory(userUUID: string, fileId: string) {
let uri = `${getMediaUrl()}content/users/${userUUID}/avatars/${fileId}` let uri = `${getMediaUrl()}content/users/${userUUID}/avatars/${fileId}`
return uri return uri

View file

@ -1,5 +1,6 @@
import { getAPIUrl } from '@services/config/config' import { getAPIUrl } from '@services/config/config'
import { import {
RequestBodyFormWithAuthHeader,
RequestBodyWithAuthHeader, RequestBodyWithAuthHeader,
errorHandling, errorHandling,
getResponseMetadata, getResponseMetadata,
@ -114,6 +115,22 @@ export async function updateOrgLanding(
return res return res
} }
export async function uploadLandingContent(
org_uuid: any,
content_file: File,
access_token: string
) {
const formData = new FormData()
formData.append('content_file', content_file)
const result = await fetch(
`${getAPIUrl()}orgs/${org_uuid}/landing/content`,
RequestBodyFormWithAuthHeader('POST', formData, null, access_token)
)
const res = await getResponseMetadata(result)
return res
}
export async function removeUserFromOrg( export async function removeUserFromOrg(
org_id: any, org_id: any,
user_id: any, user_id: any,