mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: implement image uploading to landing page
This commit is contained in:
parent
d7b6e8282b
commit
3ce7dfdcd2
7 changed files with 244 additions and 37 deletions
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 ##
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue