feat: Init explore metadata features & redesign org settings panel

This commit is contained in:
swve 2024-12-18 00:24:37 +01:00
parent 87787724c4
commit bfd27ef6e3
12 changed files with 1419 additions and 217 deletions

View file

@ -0,0 +1,41 @@
"""Organizations new model
Revision ID: 87a621284ae4
Revises: 0314ec7791e1
Create Date: 2024-12-17 22:51:50.998443
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
# revision identifiers, used by Alembic.
revision: str = '87a621284ae4'
down_revision: Union[str, None] = '0314ec7791e1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('organization', sa.Column('about', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('organization', sa.Column('socials', sa.JSON(), nullable=True))
op.add_column('organization', sa.Column('links', sa.JSON(), nullable=True))
op.add_column('organization', sa.Column('previews', sa.JSON(), nullable=True))
op.add_column('organization', sa.Column('explore', sa.Boolean(), nullable=True))
op.add_column('organization', sa.Column('label', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('organization', 'label')
op.drop_column('organization', 'explore')
op.drop_column('organization', 'previews')
op.drop_column('organization', 'links')
op.drop_column('organization', 'socials')
op.drop_column('organization', 'about')
# ### end Alembic commands ###

View file

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel, JSON, Column
from src.db.roles import RoleRead from src.db.roles import RoleRead
from src.db.organization_config import OrganizationConfig from src.db.organization_config import OrganizationConfig
@ -9,10 +9,16 @@ from src.db.organization_config import OrganizationConfig
class OrganizationBase(SQLModel): class OrganizationBase(SQLModel):
name: str name: str
description: Optional[str] description: Optional[str]
slug: str about: Optional[str]
email: str socials: Optional[dict] = Field(default={}, sa_column=Column(JSON))
links: Optional[dict] = Field(default={}, sa_column=Column(JSON))
logo_image: Optional[str] logo_image: Optional[str]
thumbnail_image: Optional[str] thumbnail_image: Optional[str]
previews: Optional[dict] = Field(default={}, sa_column=Column(JSON))
explore: Optional[bool] = Field(default=False)
label: Optional[str]
slug: str
email: str
class Organization(OrganizationBase, table=True): class Organization(OrganizationBase, table=True):
@ -26,9 +32,19 @@ class OrganizationWithConfig(BaseModel):
config: OrganizationConfig config: OrganizationConfig
class OrganizationUpdate(OrganizationBase): class OrganizationUpdate(SQLModel):
pass name: Optional[str] = None
description: Optional[str] = None
about: Optional[str] = None
socials: Optional[dict] = None
links: Optional[dict] = None
logo_image: Optional[str] = None
thumbnail_image: Optional[str] = None
previews: Optional[dict] = None
label: Optional[str] = None
slug: Optional[str] = None
email: Optional[str] = None
explore: Optional[bool] = None
class OrganizationCreate(OrganizationBase): class OrganizationCreate(OrganizationBase):
pass pass

View file

@ -37,6 +37,7 @@ from src.services.orgs.orgs import (
get_orgs_by_user_admin, get_orgs_by_user_admin,
update_org, update_org,
update_org_logo, update_org_logo,
update_org_preview,
update_org_signup_mechanism, update_org_signup_mechanism,
update_org_thumbnail, update_org_thumbnail,
) )
@ -334,6 +335,25 @@ async def api_update_org_thumbnail(
db_session=db_session, db_session=db_session,
) )
@router.put("/{org_id}/preview")
async def api_update_org_preview(
request: Request,
org_id: str,
preview_file: UploadFile,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Update org thumbnail
"""
return await update_org_preview(
request=request,
preview_file=preview_file,
org_id=org_id,
current_user=current_user,
db_session=db_session,
)
@router.get("/user/page/{page}/limit/{limit}") @router.get("/user/page/{page}/limit/{limit}")
async def api_user_orgs( async def api_user_orgs(

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_thumbnail from src.services.orgs.uploads import upload_org_logo, upload_org_preview, upload_org_thumbnail
async def get_organization( async def get_organization(
@ -174,7 +174,7 @@ async def create_org(
storage=StorageOrgConfig(enabled=True, limit=0), storage=StorageOrgConfig(enabled=True, limit=0),
ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"), ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"),
assignments=AssignmentOrgConfig(enabled=True, limit=0), assignments=AssignmentOrgConfig(enabled=True, limit=0),
payments=PaymentOrgConfig(enabled=True, stripe_key=""), payments=PaymentOrgConfig(enabled=True),
discussions=DiscussionOrgConfig(enabled=True, limit=0), discussions=DiscussionOrgConfig(enabled=True, limit=0),
analytics=AnalyticsOrgConfig(enabled=True, limit=0), analytics=AnalyticsOrgConfig(enabled=True, limit=0),
collaboration=CollaborationOrgConfig(enabled=True, limit=0), collaboration=CollaborationOrgConfig(enabled=True, limit=0),
@ -458,6 +458,31 @@ async def update_org_thumbnail(
return {"detail": "Thumbnail updated"} return {"detail": "Thumbnail updated"}
async def update_org_preview(
request: Request,
preview_file: UploadFile,
org_id: str,
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)
# Upload logo
name_in_disk = await upload_org_preview(preview_file, org.org_uuid)
return {"name_in_disk": name_in_disk}
async def delete_org( async def delete_org(
request: Request, request: Request,
@ -675,6 +700,19 @@ async def get_org_join_mechanism(
return signup_mechanism return signup_mechanism
async def upload_org_preview_service(
preview_file: UploadFile,
org_uuid: str,
) -> dict:
# No need for request or current_user since we're not doing RBAC checks for previews
# Upload preview
name_in_disk = await upload_org_preview(preview_file, org_uuid)
return {
"detail": "Preview uploaded successfully",
"filename": name_in_disk
}
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##

View file

@ -31,3 +31,18 @@ async def upload_org_thumbnail(thumbnail_file, org_uuid):
) )
return name_in_disk return name_in_disk
async def upload_org_preview(file, org_uuid: str) -> str:
contents = file.file.read()
name_in_disk = f"{uuid4()}.{file.filename.split('.')[-1]}"
await upload_content(
"previews",
"orgs",
org_uuid,
contents,
name_in_disk,
)
return name_in_disk

View file

@ -1,17 +1,52 @@
'use client' 'use client'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { Info } from 'lucide-react' import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral' 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'
export type OrgParams = { export type OrgParams = {
subpage: string subpage: string
orgslug: string orgslug: string
} }
interface TabItem {
id: string
label: string
icon: LucideIcon
}
const SETTING_TABS: TabItem[] = [
{ id: 'general', label: 'General', icon: TextIcon },
{ id: 'previews', label: 'Images & Previews', icon: ImageIcon },
{ id: 'socials', label: 'Socials', icon: Share2Icon },
]
function TabLink({ tab, isActive, orgslug }: {
tab: TabItem,
isActive: boolean,
orgslug: string
}) {
return (
<Link href={getUriWithOrg(orgslug, '') + `/dash/org/settings/${tab.id}`}>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${
isActive ? 'border-b-4' : 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2.5">
<tab.icon size={16} />
<div>{tab.label}</div>
</div>
</div>
</Link>
)
}
function OrgPage({ params }: { params: OrgParams }) { function OrgPage({ params }: { params: OrgParams }) {
const [H1Label, setH1Label] = React.useState('') const [H1Label, setH1Label] = React.useState('')
const [H2Label, setH2Label] = React.useState('') const [H2Label, setH2Label] = React.useState('')
@ -20,6 +55,12 @@ function OrgPage({ params }: { params: OrgParams }) {
if (params.subpage == 'general') { if (params.subpage == 'general') {
setH1Label('General') setH1Label('General')
setH2Label('Manage your organization settings') setH2Label('Manage your organization settings')
} else if (params.subpage == 'previews') {
setH1Label('Previews')
setH2Label('Manage your organization previews')
} else if (params.subpage == 'socials') {
setH1Label('Socials')
setH2Label('Manage your organization social media links')
} }
} }
@ -29,9 +70,9 @@ function OrgPage({ params }: { params: OrgParams }) {
return ( return (
<div className="h-full w-full bg-[#f8f8f8]"> <div className="h-full w-full bg-[#f8f8f8]">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] shadow-[0px_4px_16px_rgba(0,0,0,0.02)]"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow">
<BreadCrumbs type="org"></BreadCrumbs> <BreadCrumbs type="org"></BreadCrumbs>
<div className="my-2 py-3"> <div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1"> <div className="w-100 flex flex-col space-y-1">
<div className="pt-3 flex font-bold text-4xl tracking-tighter"> <div className="pt-3 flex font-bold text-4xl tracking-tighter">
{H1Label} {H1Label}
@ -41,25 +82,15 @@ function OrgPage({ params }: { params: OrgParams }) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex space-x-5 font-black text-sm"> <div className="flex space-x-0.5 font-black text-sm">
<Link {SETTING_TABS.map((tab) => (
href={ <TabLink
getUriWithOrg(params.orgslug, '') + `/dash/org/settings/general` key={tab.id}
} tab={tab}
> isActive={params.subpage === tab.id}
<div orgslug={params.orgslug}
className={`py-2 w-fit text-center border-black transition-all ease-linear ${ />
params.subpage.toString() === 'general' ))}
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Info size={16} />
<div>General</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
<div className="h-6"></div> <div className="h-6"></div>
@ -70,6 +101,8 @@ function OrgPage({ params }: { params: OrgParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
> >
{params.subpage == 'general' ? <OrgEditGeneral /> : ''} {params.subpage == 'general' ? <OrgEditGeneral /> : ''}
{params.subpage == 'previews' ? <OrgEditImages /> : ''}
{params.subpage == 'socials' ? <OrgEditSocials /> : ''}
</motion.div> </motion.div>
</div> </div>
) )

View file

@ -1,106 +1,112 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { Field, Form, Formik } from 'formik' import { Form, Formik } from 'formik'
import * as Yup from 'yup'
import { import {
updateOrganization, updateOrganization,
uploadOrganizationLogo,
uploadOrganizationThumbnail,
} from '@services/settings/org' } from '@services/settings/org'
import { UploadCloud, Info } from 'lucide-react'
import { revalidateTags } from '@services/utils/ts/requests' import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
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 { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media' import { toast } from 'react-hot-toast'
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs" import { Input } from "@components/ui/input"
import { Toaster, toast } from 'react-hot-toast'; import { Textarea } from "@components/ui/textarea"
import { constructAcceptValue } from '@/lib/constants'; import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select"
import { Switch } from "@components/ui/switch"
import { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
import Image from 'next/image'
import learnhouseIcon from '@public/learnhouse_logo.png'
const SUPPORTED_FILES = constructAcceptValue(['png', 'jpg']) const ORG_LABELS = [
{ value: 'languages', label: '🌐 Languages' },
{ value: 'business', label: '💰 Business' },
{ value: 'ecommerce', label: '🛍 E-commerce' },
{ value: 'gaming', label: '🎮 Gaming' },
{ value: 'music', label: '🎸 Music' },
{ value: 'sports', label: '⚽ Sports' },
{ value: 'cars', label: '🚗 Cars' },
{ value: 'sales_marketing', label: '🚀 Sales & Marketing' },
{ value: 'tech', label: '💻 Tech' },
{ value: 'photo_video', label: '📸 Photo & Video' },
{ value: 'pets', label: '🐕 Pets' },
{ value: 'personal_development', label: '📚 Personal Development' },
{ value: 'real_estate', label: '🏠 Real Estate' },
{ value: 'beauty_fashion', label: '👠 Beauty & Fashion' },
{ value: 'travel', label: '✈️ Travel' },
{ value: 'productivity', label: '⏳ Productivity' },
{ value: 'health_fitness', label: '🍎 Health & Fitness' },
{ value: 'finance', label: '📈 Finance' },
{ value: 'arts_crafts', label: '🎨 Arts & Crafts' },
{ value: 'education', label: '📚 Education' },
{ value: 'stem', label: '🔬 STEM' },
{ value: 'humanities', label: '📖 Humanities' },
{ value: 'professional_skills', label: '💼 Professional Skills' },
{ value: 'digital_skills', label: '💻 Digital Skills' },
{ value: 'creative_arts', label: '🎨 Creative Arts' },
{ value: 'social_sciences', label: '🌍 Social Sciences' },
{ value: 'test_prep', label: '✍️ Test Preparation' },
{ value: 'vocational', label: '🔧 Vocational Training' },
{ value: 'early_education', label: '🎯 Early Education' },
] as const
const validationSchema = Yup.object().shape({
name: Yup.string()
.required('Name is required')
.max(60, 'Organization name must be 60 characters or less'),
description: Yup.string()
.required('Short description is required')
.max(100, 'Short description must be 100 characters or less'),
about: Yup.string()
.optional()
.max(400, 'About text must be 400 characters or less'),
label: Yup.string().required('Organization label is required'),
explore: Yup.boolean(),
})
interface OrganizationValues { interface OrganizationValues {
name: string name: string
description: string description: string
slug: string about: string
logo: string label: string
email: string explore: boolean
thumbnail: string
} }
function OrgEditGeneral() { const OrgEditGeneral: React.FC = () => {
const router = useRouter() const router = useRouter()
const session = useLHSession() as any const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any const org = useOrg() as any
const [selectedTab, setSelectedTab] = useState<'logo' | 'thumbnail'>('logo');
const [localLogo, setLocalLogo] = useState<string | null>(null);
const [localThumbnail, setLocalThumbnail] = useState<string | null>(null);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const initialValues: OrganizationValues = {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
setLocalLogo(URL.createObjectURL(file))
const loadingToast = toast.loading('Uploading logo...');
try {
await uploadOrganizationLogo(org.id, file, access_token)
await new Promise((r) => setTimeout(r, 1500))
toast.success('Logo Updated', { id: loadingToast });
router.refresh()
} catch (err) {
toast.error('Failed to upload logo', { id: loadingToast });
}
}
}
const handleThumbnailChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0];
setLocalThumbnail(URL.createObjectURL(file));
const loadingToast = toast.loading('Uploading thumbnail...');
try {
await uploadOrganizationThumbnail(org.id, file, access_token);
await new Promise((r) => setTimeout(r, 1500));
toast.success('Thumbnail Updated', { id: loadingToast });
router.refresh()
} catch (err) {
toast.error('Failed to upload thumbnail', { id: loadingToast });
}
}
};
const handleImageButtonClick = (inputId: string) => (event: React.MouseEvent) => {
event.preventDefault(); // Prevent form submission
document.getElementById(inputId)?.click();
};
let orgValues: OrganizationValues = {
name: org?.name, name: org?.name,
description: org?.description, description: org?.description || '',
slug: org?.slug, about: org?.about || '',
logo: org?.logo, label: org?.label || '',
email: org?.email, explore: org?.explore ?? true,
thumbnail: org?.thumbnail,
} }
const updateOrg = async (values: OrganizationValues) => { const updateOrg = async (values: OrganizationValues) => {
const loadingToast = toast.loading('Updating organization...'); const loadingToast = toast.loading('Updating organization...')
try { try {
await updateOrganization(org.id, values, access_token) await updateOrganization(org.id, values, access_token)
await revalidateTags(['organizations'], org.slug) await revalidateTags(['organizations'], org.slug)
toast.success('Organization Updated', { id: loadingToast }); mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
toast.success('Organization Updated', { id: loadingToast })
} catch (err) { } catch (err) {
toast.error('Failed to update organization', { id: loadingToast }); toast.error('Failed to update organization', { id: loadingToast })
} }
} }
useEffect(() => {}, [org])
return ( return (
<div className="sm:ml-10 sm:mr-10 ml-0 mr-0 mx-auto bg-white rounded-xl shadow-sm px-6 py-5 sm:mb-0 mb-16"> <div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow ">
<Toaster />
<Formik <Formik
enableReinitialize enableReinitialize
initialValues={orgValues} initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => { onSubmit={(values, { setSubmitting }) => {
setTimeout(() => { setTimeout(() => {
setSubmitting(false) setSubmitting(false)
@ -108,129 +114,145 @@ function OrgEditGeneral() {
}, 400) }, 400)
}} }}
> >
{({ isSubmitting }) => ( {({ isSubmitting, values, handleChange, errors, touched, setFieldValue }) => (
<Form> <Form>
<div className="flex flex-col lg:flex-row lg:space-x-8"> <div className="flex flex-col gap-0">
<div className="w-full lg:w-1/2 mb-8 lg:mb-0"> <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
<label className="block mb-2 font-bold" htmlFor="name"> <h1 className="font-bold text-xl text-gray-800">
Name Organization Settings
</label> </h1>
<Field <h2 className="text-gray-500 text-md">
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" Manage your organization's profile and settings
type="text" </h2>
name="name"
/>
<label className="block mb-2 font-bold" htmlFor="description">
Description
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="text"
name="description"
/>
<label className="block mb-2 font-bold" htmlFor="slug">
Slug
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg bg-gray-200 cursor-not-allowed"
disabled
type="text"
name="slug"
/>
<label className="block mb-2 font-bold" htmlFor="email">
Email
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="email"
name="email"
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black"
>
Submit
</button>
</div> </div>
<div className="w-full lg:w-1/2"> <div className="flex flex-col lg:flex-row lg:space-x-8 mt-0 mx-5 my-5">
<Tabs defaultValue="logo" className="w-full"> <div className="w-full space-y-6">
<TabsList className="grid w-full grid-cols-2 mb-6 sm:mb-10"> <div className="space-y-4">
<TabsTrigger value="logo">Logo</TabsTrigger> <div>
<TabsTrigger value="thumbnail">Thumbnail</TabsTrigger> <Label htmlFor="name">
</TabsList> Organization Name
<TabsContent value="logo"> <span className="text-gray-500 text-sm ml-2">
<div className="flex flex-col space-y-3"> ({60 - (values.name?.length || 0)} characters left)
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10"> </span>
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10"> </Label>
<div <Input
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white" id="name"
style={{ backgroundImage: `url(${localLogo || getOrgLogoMediaDirectory(org?.org_uuid, org?.logo_image)})` }} name="name"
/> value={values.name}
</div> onChange={handleChange}
<div className="flex justify-center items-center"> placeholder="Organization Name"
<input maxLength={60}
type="file" />
id="fileInput" {touched.name && errors.name && (
accept={SUPPORTED_FILES} <p className="text-red-500 text-sm mt-1">{errors.name}</p>
style={{ display: 'none' }} )}
onChange={handleFileChange}
/>
<button
type="button"
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 py-2 mt-4 flex"
onClick={handleImageButtonClick('fileInput')}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Logo</span>
</button>
</div>
</div>
<div className="flex text-xs space-x-2 items-center text-gray-500 justify-center">
<Info size={13} />
<p>Accepts PNG, JPG</p>
</div>
</div> </div>
</TabsContent>
<TabsContent value="thumbnail"> <div>
<div className="flex flex-col space-y-3"> <Label htmlFor="description">
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10"> Short Description
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10"> <span className="text-gray-500 text-sm ml-2">
<div ({100 - (values.description?.length || 0)} characters left)
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white" </span>
style={{ backgroundImage: `url(${localThumbnail || getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image)})` }} </Label>
/> <Input
</div> id="description"
<div className="flex justify-center items-center"> name="description"
<input value={values.description}
type="file" onChange={handleChange}
accept={SUPPORTED_FILES} placeholder="Brief description of your organization"
id="thumbnailInput" maxLength={100}
style={{ display: 'none' }} />
onChange={handleThumbnailChange} {touched.description && errors.description && (
/> <p className="text-red-500 text-sm mt-1">{errors.description}</p>
<button )}
type="button"
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 py-2 mt-4 flex"
onClick={handleImageButtonClick('thumbnailInput')}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Thumbnail</span>
</button>
</div>
</div>
<div className="flex text-xs space-x-2 items-center text-gray-500 justify-center">
<Info size={13} />
<p>Accepts PNG, JPG</p>
</div>
</div> </div>
</TabsContent>
</Tabs> <div>
<Label htmlFor="label">Organization Label</Label>
<Select
value={values.label}
onValueChange={(value) => setFieldValue('label', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select organization label" />
</SelectTrigger>
<SelectContent>
{ORG_LABELS.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{touched.label && errors.label && (
<p className="text-red-500 text-sm mt-1">{errors.label}</p>
)}
</div>
<div>
<Label htmlFor="about">
About Organization
<span className="text-gray-500 text-sm ml-2">
({400 - (values.about?.length || 0)} characters left)
</span>
</Label>
<Textarea
id="about"
name="about"
value={values.about}
onChange={handleChange}
placeholder="Detailed description of your organization"
className="min-h-[150px]"
maxLength={400}
/>
{touched.about && errors.about && (
<p className="text-red-500 text-sm mt-1">{errors.about}</p>
)}
</div>
<div className="flex items-center justify-between space-x-2 mt-6 bg-gray-50/50 p-4 rounded-lg nice-shadow">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Image
quality={100}
width={120}
src={learnhouseIcon}
alt="LearnHouse"
className="rounded-lg"
/>
<span className="px-2 py-1 mt-1 bg-black rounded-md text-[10px] font-semibold text-white">
EXPLORE
</span>
</div>
<div className="space-y-0.5">
<Label className="text-base">Showcase in LearnHouse Explore</Label>
<p className="text-sm text-gray-500">
Share your organization's courses and content with the LearnHouse community.
Enable this to help learners discover your valuable educational resources.
</p>
</div>
</div>
<Switch
name="explore"
checked={values.explore}
onCheckedChange={(checked) => setFieldValue('explore', checked)}
/>
</div>
</div>
</div>
</div>
<div className="flex flex-row-reverse mt-0 mx-5 mb-5">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div> </div>
</div> </div>
</Form> </Form>

View file

@ -0,0 +1,735 @@
'use client'
import React, { useState } from 'react'
import { UploadCloud, Info, Plus, X, Video, GripVertical, Image, Layout, Images, StarIcon, ImageIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getOrgLogoMediaDirectory, getOrgPreviewMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media'
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs"
import { toast } from 'react-hot-toast'
import { constructAcceptValue } from '@/lib/constants'
import { uploadOrganizationLogo, uploadOrganizationThumbnail, uploadOrganizationPreview, updateOrganization } from '@services/settings/org'
import { cn } from '@/lib/utils'
import { Input } from "@components/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { SiLoom, SiYoutube } from '@icons-pack/react-simple-icons'
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'
const SUPPORTED_FILES = constructAcceptValue(['png', 'jpg'])
type Preview = {
id: string;
url: string;
type: 'image' | 'youtube' | 'loom';
filename?: string;
thumbnailUrl?: string;
order: number;
};
// Update the height constant
const PREVIEW_HEIGHT = 'h-28' // Reduced height
// Add this type for the video service selection
type VideoService = 'youtube' | 'loom' | null;
// Add this constant for consistent sizing
const DIALOG_ICON_SIZE = 'w-16 h-16'
// Add this constant at the top with other constants
const ADD_PREVIEW_OPTIONS = [
{
id: 'image',
title: 'Upload Images',
description: 'PNG, JPG (max 5MB)',
icon: UploadCloud,
color: 'blue',
onClick: () => document.getElementById('previewInput')?.click()
},
{
id: 'youtube',
title: 'YouTube',
description: 'Add YouTube video',
icon: SiYoutube,
color: 'red',
onClick: (setSelectedService: Function) => setSelectedService('youtube')
},
{
id: 'loom',
title: 'Loom',
description: 'Add Loom video',
icon: SiLoom,
color: 'blue',
onClick: (setSelectedService: Function) => setSelectedService('loom')
}
] as const;
export default function OrgEditImages() {
const router = useRouter()
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any
const [localLogo, setLocalLogo] = useState<string | null>(null)
const [localThumbnail, setLocalThumbnail] = useState<string | null>(null)
const [isLogoUploading, setIsLogoUploading] = useState(false)
const [isThumbnailUploading, setIsThumbnailUploading] = useState(false)
const [previews, setPreviews] = useState<Preview[]>(() => {
// Initialize with image previews
const imagePreviews = (org?.previews?.images || [])
.filter((item: any) => item?.filename) // Filter out empty filenames
.map((item: any, index: number) => ({
id: item.filename,
url: getOrgThumbnailMediaDirectory(org?.org_uuid, item.filename),
filename: item.filename,
type: 'image' as const,
order: item.order ?? index // Use existing order or fallback to index
}));
// Initialize with video previews
const videoPreviews = (org?.previews?.videos || [])
.filter((video: any) => video && video.id)
.map((video: any, index: number) => ({
id: video.id,
url: video.url,
type: video.type as 'youtube' | 'loom',
thumbnailUrl: video.type === 'youtube'
? `https://img.youtube.com/vi/${video.id}/maxresdefault.jpg`
: '',
filename: '',
order: video.order ?? (imagePreviews.length + index) // Use existing order or fallback to index after images
}));
const allPreviews = [...imagePreviews, ...videoPreviews];
return allPreviews.sort((a, b) => a.order - b.order);
});
const [isPreviewUploading, setIsPreviewUploading] = useState(false)
const [videoUrl, setVideoUrl] = useState('')
const [videoDialogOpen, setVideoDialogOpen] = useState(false)
const [selectedService, setSelectedService] = useState<VideoService>(null)
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
setLocalLogo(URL.createObjectURL(file))
setIsLogoUploading(true)
const loadingToast = toast.loading('Uploading logo...')
try {
await uploadOrganizationLogo(org.id, file, access_token)
await new Promise((r) => setTimeout(r, 1500))
toast.success('Logo Updated', { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to upload logo', { id: loadingToast })
} finally {
setIsLogoUploading(false)
}
}
}
const handleThumbnailChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
setLocalThumbnail(URL.createObjectURL(file))
setIsThumbnailUploading(true)
const loadingToast = toast.loading('Uploading thumbnail...')
try {
await uploadOrganizationThumbnail(org.id, file, access_token)
await new Promise((r) => setTimeout(r, 1500))
toast.success('Thumbnail Updated', { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to upload thumbnail', { id: loadingToast })
} finally {
setIsThumbnailUploading(false)
}
}
}
const handleImageButtonClick = (inputId: string) => (event: React.MouseEvent) => {
event.preventDefault()
document.getElementById(inputId)?.click()
}
const handlePreviewUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const files = Array.from(event.target.files)
const remainingSlots = 4 - previews.length
if (files.length > remainingSlots) {
toast.error(`You can only upload ${remainingSlots} more preview${remainingSlots === 1 ? '' : 's'}`)
return
}
setIsPreviewUploading(true)
const loadingToast = toast.loading(`Uploading ${files.length} preview${files.length === 1 ? '' : 's'}...`)
try {
const uploadPromises = files.map(async (file) => {
const response = await uploadOrganizationPreview(org.id, file, access_token)
return {
id: response.name_in_disk,
url: URL.createObjectURL(file),
filename: response.name_in_disk,
type: 'image' as const,
order: previews.length // Add new items at the end
}
})
const newPreviews = await Promise.all(uploadPromises)
const updatedPreviews = [...previews, ...newPreviews]
await updateOrganization(org.id, {
previews: {
images: updatedPreviews
.filter(p => p.type === 'image')
.map(p => ({
filename: p.filename,
order: p.order
})),
videos: updatedPreviews
.filter(p => p.type === 'youtube' || p.type === 'loom')
.map(p => ({
type: p.type,
url: p.url,
id: p.id,
order: p.order
}))
}
}, access_token)
setPreviews(updatedPreviews)
toast.success(`${files.length} preview${files.length === 1 ? '' : 's'} added`, { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to upload previews', { id: loadingToast })
} finally {
setIsPreviewUploading(false)
}
}
}
const removePreview = async (id: string) => {
const loadingToast = toast.loading('Removing preview...')
try {
const updatedPreviews = previews.filter(p => p.id !== id)
const updatedPreviewFilenames = updatedPreviews.map(p => p.filename)
await updateOrganization(org.id, {
previews: {
images: updatedPreviewFilenames
}
}, access_token)
setPreviews(updatedPreviews)
toast.success('Preview removed', { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to remove preview', { id: loadingToast })
}
}
const extractVideoId = (url: string, type: 'youtube' | 'loom'): string | null => {
if (type === 'youtube') {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/
const match = url.match(regex)
return match ? match[1] : null
} else if (type === 'loom') {
const regex = /(?:loom\.com\/(?:share|embed)\/)([a-zA-Z0-9]+)/
const match = url.match(regex)
return match ? match[1] : null
}
return null
}
const handleVideoSubmit = async (type: 'youtube' | 'loom') => {
const videoId = extractVideoId(videoUrl, type);
if (!videoId) {
toast.error(`Invalid ${type} URL`);
return;
}
// Check if video already exists
if (previews.some(preview => preview.id === videoId)) {
toast.error('This video has already been added');
return;
}
const loadingToast = toast.loading('Adding video preview...');
try {
const thumbnailUrl = type === 'youtube'
? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
: '';
const newPreview: Preview = {
id: videoId,
url: videoUrl,
type,
thumbnailUrl,
filename: '',
order: previews.length // Add new items at the end
};
const updatedPreviews = [...previews, newPreview];
await updateOrganization(org.id, {
previews: {
images: updatedPreviews
.filter(p => p.type === 'image')
.map(p => ({
filename: p.filename,
order: p.order
})),
videos: updatedPreviews
.filter(p => p.type === 'youtube' || p.type === 'loom')
.map(p => ({
type: p.type,
url: p.url,
id: p.id,
order: p.order
}))
}
}, access_token);
setPreviews(updatedPreviews);
setVideoUrl('');
setVideoDialogOpen(false);
toast.success('Video preview added', { id: loadingToast });
router.refresh();
} catch (err) {
toast.error('Failed to add video preview', { id: loadingToast });
}
};
const handleDragEnd = async (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(previews);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update order numbers
const reorderedItems = items.map((item, index) => ({
...item,
order: index
}));
setPreviews(reorderedItems);
// Update the order in the backend
const loadingToast = toast.loading('Updating preview order...');
try {
await updateOrganization(org.id, {
previews: {
images: reorderedItems
.filter(p => p.type === 'image')
.map(p => ({
filename: p.filename,
order: p.order
})),
videos: reorderedItems
.filter(p => p.type === 'youtube' || p.type === 'loom')
.map(p => ({
type: p.type,
url: p.url,
id: p.id,
order: p.order
}))
}
}, access_token);
toast.success('Preview order updated', { id: loadingToast });
router.refresh();
} catch (err) {
toast.error('Failed to update preview order', { id: loadingToast });
setPreviews(previews);
}
};
// Add function to reset video dialog state
const resetVideoDialog = () => {
setSelectedService(null)
setVideoUrl('')
}
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow px-3 py-3 sm:mb-0 mb-16">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mb-2 rounded-md">
<h1 className="font-bold text-xl text-gray-800">
Images & Previews
</h1>
<h2 className="text-gray-500 text-md">
Manage your organization's logo, thumbnail, and preview images
</h2>
</div>
<Tabs defaultValue="logo" className="w-full">
<TabsList className="grid w-full grid-cols-3 p-1 bg-gray-100 rounded-lg">
<TabsTrigger
value="logo"
className="data-[state=active]:bg-white data-[state=active]:shadow-sm transition-all flex items-center space-x-2"
>
<StarIcon size={16} />
<span>Logo</span>
</TabsTrigger>
<TabsTrigger
value="thumbnail"
className="data-[state=active]:bg-white data-[state=active]:shadow-sm transition-all flex items-center space-x-2"
>
<ImageIcon size={16} />
<span>Thumbnail</span>
</TabsTrigger>
<TabsTrigger
value="previews"
className="data-[state=active]:bg-white data-[state=active]:shadow-sm transition-all flex items-center space-x-2"
>
<Images size={16} />
<span>Previews</span>
</TabsTrigger>
</TabsList>
<TabsContent value="logo" className="mt-2">
<div className="flex flex-col space-y-5 w-full">
<div className="w-full bg-gradient-to-b from-gray-50 to-white rounded-xl transition-all duration-300 py-8">
<div className="flex flex-col justify-center items-center space-y-8">
<div className="relative group">
<div
className={cn(
"w-[200px] sm:w-[250px] h-[100px] sm:h-[125px] bg-contain bg-no-repeat bg-center rounded-lg shadow-md bg-white",
"border-2 border-gray-100 hover:border-blue-200 transition-all duration-300",
isLogoUploading && "opacity-50"
)}
style={{ backgroundImage: `url(${localLogo || getOrgLogoMediaDirectory(org?.org_uuid, org?.logo_image)})` }}
/>
</div>
<div className="flex flex-col items-center space-y-4">
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
disabled={isLogoUploading}
className={cn(
"font-medium text-sm px-6 py-2.5 rounded-full",
"bg-gradient-to-r from-blue-500 to-blue-600 text-white",
"hover:from-blue-600 hover:to-blue-700",
"shadow-sm hover:shadow transition-all duration-300",
"flex items-center space-x-2",
isLogoUploading && "opacity-75 cursor-not-allowed"
)}
onClick={handleImageButtonClick('fileInput')}
>
<UploadCloud size={18} className={cn("", isLogoUploading && "animate-bounce")} />
<span>{isLogoUploading ? 'Uploading...' : 'Upload New Logo'}</span>
</button>
<div className="flex flex-col text-xs space-y-2 items-center text-gray-500">
<div className="flex items-center space-x-2 bg-blue-50 text-blue-700 px-3 py-1.5 rounded-full">
<Info size={14} />
<p className="font-medium">Accepts PNG, JPG (max 5MB)</p>
</div>
<p className="text-gray-400">Recommended size: 200x100 pixels</p>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="thumbnail" className="mt-2">
<div className="flex flex-col space-y-5 w-full">
<div className="w-full bg-gradient-to-b from-gray-50 to-white rounded-xl transition-all duration-300 py-8">
<div className="flex flex-col justify-center items-center space-y-8">
<div className="relative group">
<div
className={cn(
"w-[200px] sm:w-[250px] h-[100px] sm:h-[125px] bg-contain bg-no-repeat bg-center rounded-lg shadow-md bg-white",
"border-2 border-gray-100 hover:border-purple-200 transition-all duration-300",
isThumbnailUploading && "opacity-50"
)}
style={{ backgroundImage: `url(${localThumbnail || getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image)})` }}
/>
</div>
<div className="flex flex-col items-center space-y-4">
<input
type="file"
id="thumbnailInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleThumbnailChange}
/>
<button
type="button"
disabled={isThumbnailUploading}
className={cn(
"font-medium text-sm px-6 py-2.5 rounded-full",
"bg-gradient-to-r from-purple-500 to-purple-600 text-white",
"hover:from-purple-600 hover:to-purple-700",
"shadow-sm hover:shadow transition-all duration-300",
"flex items-center space-x-2",
isThumbnailUploading && "opacity-75 cursor-not-allowed"
)}
onClick={handleImageButtonClick('thumbnailInput')}
>
<UploadCloud size={18} className={cn("", isThumbnailUploading && "animate-bounce")} />
<span>{isThumbnailUploading ? 'Uploading...' : 'Upload New Thumbnail'}</span>
</button>
<div className="flex flex-col text-xs space-y-2 items-center text-gray-500">
<div className="flex items-center space-x-2 bg-purple-50 text-purple-700 px-3 py-1.5 rounded-full">
<Info size={14} />
<p className="font-medium">Accepts PNG, JPG (max 5MB)</p>
</div>
<p className="text-gray-400">Recommended size: 200x100 pixels</p>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="previews" className="mt-4">
<div className="flex flex-col space-y-5 w-full">
<div className="w-full bg-gradient-to-b from-gray-50 to-white rounded-xl transition-all duration-300 py-6">
<div className="flex flex-col justify-center items-center space-y-6">
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="previews" direction="horizontal">
{(provided) => (
<div
className={cn(
"flex gap-4 w-full max-w-5xl p-4 overflow-x-auto pb-6",
previews.length === 0 && "justify-center"
)}
{...provided.droppableProps}
ref={provided.innerRef}
>
{previews.map((preview, index) => (
<Draggable
key={preview.id}
draggableId={preview.id}
index={index}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={cn(
"relative group flex-shrink-0",
"w-48",
snapshot.isDragging ? "scale-105 z-50" : "hover:scale-102",
)}
>
<button
onClick={() => removePreview(preview.id)}
className={cn(
"absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5",
"opacity-0 group-hover:opacity-100 z-10 shadow-sm",
"transition-opacity duration-200"
)}
>
<X size={14} />
</button>
<div
{...provided.dragHandleProps}
className={cn(
"absolute -top-2 -left-2 bg-gray-600 hover:bg-gray-700 text-white rounded-full p-1.5",
"opacity-0 group-hover:opacity-100 cursor-grab active:cursor-grabbing z-10 shadow-sm",
"transition-opacity duration-200"
)}
>
<GripVertical size={14} />
</div>
{preview.type === 'image' ? (
<div
className={cn(
`w-full ${PREVIEW_HEIGHT} bg-contain bg-no-repeat bg-center rounded-xl bg-white`,
"border border-gray-200 hover:border-gray-300",
"transition-colors duration-200",
snapshot.isDragging ? "shadow-lg" : "shadow-sm hover:shadow-md"
)}
style={{
backgroundImage: `url(${getOrgPreviewMediaDirectory(org?.org_uuid, preview.id)})`,
}}
/>
) : (
<div className={cn(
`w-full ${PREVIEW_HEIGHT} relative rounded-xl overflow-hidden`,
"border border-gray-200 hover:border-gray-300 transition-colors duration-200",
snapshot.isDragging ? "shadow-lg" : "shadow-sm hover:shadow-md"
)}>
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${preview.thumbnailUrl})` }}
/>
<div className="absolute inset-0 bg-black bg-opacity-40 backdrop-blur-[2px] flex items-center justify-center">
{preview.type === 'youtube' ? (
<SiYoutube className="w-10 h-10 text-red-500" />
) : (
<SiLoom className="w-10 h-10 text-blue-500" />
)}
</div>
</div>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
{previews.length < 4 && (
<div className={cn(
"flex-shrink-0 w-48",
previews.length === 0 && "m-0"
)}>
<Dialog open={videoDialogOpen} onOpenChange={(open) => {
setVideoDialogOpen(open);
if (!open) resetVideoDialog();
}}>
<DialogTrigger asChild>
<button
className={cn(
`w-full ${PREVIEW_HEIGHT}`,
"border-2 border-dashed border-gray-200 rounded-xl",
"hover:border-blue-300 hover:bg-blue-50/50 transition-all duration-200",
"flex flex-col items-center justify-center space-y-2 group"
)}
>
<div className="bg-blue-50 rounded-full p-2 group-hover:bg-blue-100 transition-colors duration-200">
<Plus size={20} className="text-blue-500" />
</div>
<span className="text-sm font-medium text-gray-600">Add Preview</span>
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Preview</DialogTitle>
</DialogHeader>
<div className={cn(
"p-6",
selectedService ? "space-y-4" : "grid grid-cols-3 gap-6"
)}>
{!selectedService ? (
<>
{ADD_PREVIEW_OPTIONS.map((option) => (
<button
key={option.id}
onClick={() => option.id === 'image'
? option.onClick()
: option.onClick(setSelectedService)
}
className={cn(
"w-full aspect-square rounded-2xl border-2 border-dashed",
`hover:border-${option.color}-300 hover:bg-${option.color}-50/50`,
"transition-all duration-200",
"flex flex-col items-center justify-center space-y-4",
option.id === 'image' && isPreviewUploading && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
DIALOG_ICON_SIZE,
`rounded-full bg-${option.color}-50`,
"flex items-center justify-center"
)}>
<option.icon className={`w-8 h-8 text-${option.color}-500`} />
</div>
<div className="text-center">
<p className="font-medium text-gray-700">{option.title}</p>
<p className="text-sm text-gray-500 mt-1">{option.description}</p>
</div>
</button>
))}
<input
type="file"
id="previewInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handlePreviewUpload}
multiple
/>
</>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center",
selectedService === 'youtube' ? "bg-red-50" : "bg-blue-50"
)}>
{selectedService === 'youtube' ? (
<SiYoutube className="w-5 h-5 text-red-500" />
) : (
<SiLoom className="w-5 h-5 text-blue-500" />
)}
</div>
<div>
<h3 className="font-medium text-gray-900">
{selectedService === 'youtube' ? 'Add YouTube Video' : 'Add Loom Video'}
</h3>
<p className="text-sm text-gray-500">
{selectedService === 'youtube'
? 'Paste your YouTube video URL'
: 'Paste your Loom video URL'}
</p>
</div>
</div>
<button
onClick={() => setSelectedService(null)}
className="text-gray-400 hover:text-gray-500 transition-colors"
>
<X size={20} />
</button>
</div>
<div className="space-y-3">
<Input
id="videoUrlInput"
placeholder={selectedService === 'youtube'
? 'https://youtube.com/watch?v=...'
: 'https://www.loom.com/share/...'}
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
className="w-full"
autoFocus
/>
<Button
onClick={() => handleVideoSubmit(selectedService)}
className={cn(
"w-full",
selectedService === 'youtube'
? "bg-red-500 hover:bg-red-600"
: "bg-blue-500 hover:bg-blue-600"
)}
disabled={!videoUrl}
>
Add Video
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
)}
</div>
)}
</Droppable>
</DragDropContext>
<div className="flex items-center space-x-2 bg-gray-50 text-gray-600 px-4 py-2 rounded-full">
<Info size={14} />
<p className="text-sm">Drag to reorder Maximum 4 previews Supports images & videos</p>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View file

@ -0,0 +1,236 @@
'use client'
import React from 'react'
import { Form, Formik } from 'formik'
import { updateOrganization } from '@services/settings/org'
import { revalidateTags } from '@services/utils/ts/requests'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { toast } from 'react-hot-toast'
import { Input } from "@components/ui/input"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import {
SiX,
SiFacebook,
SiInstagram,
SiYoutube
} from '@icons-pack/react-simple-icons'
import { Plus, X as XIcon } from "lucide-react"
import { useRouter } from 'next/navigation'
import { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
interface OrganizationValues {
socials: {
twitter?: string
facebook?: string
instagram?: string
linkedin?: string
youtube?: string
}
links: {
[key: string]: string
}
}
export default function OrgEditSocials() {
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any
const router = useRouter()
const initialValues: OrganizationValues = {
socials: org?.socials || {},
links: org?.links || {}
}
const updateOrg = async (values: OrganizationValues) => {
const loadingToast = toast.loading('Updating organization...')
try {
await updateOrganization(org.id, values, access_token)
await revalidateTags(['organizations'], org.slug)
mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
toast.success('Organization Updated', { id: loadingToast })
} catch (err) {
toast.error('Failed to update organization', { id: loadingToast })
}
}
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false)
updateOrg(values)
}, 400)
}}
>
{({ isSubmitting, values, handleChange, setFieldValue }) => (
<Form>
<div className="flex flex-col gap-0">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
<h1 className="font-bold text-xl text-gray-800">
Social Links
</h1>
<h2 className="text-gray-500 text-md">
Manage your organization's social media presence
</h2>
</div>
<div className="flex flex-col lg:flex-row lg:space-x-8 mt-0 mx-5 my-5">
<div className="w-full space-y-6">
<div>
<Label className="text-lg font-semibold">Social Links</Label>
<div className="space-y-3 bg-gray-50/50 p-4 rounded-lg nice-shadow mt-2">
<div className="grid gap-3">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#1DA1F2]/10 rounded-md">
<SiX size={16} color="#1DA1F2"/>
</div>
<Input
id="socials.twitter"
name="socials.twitter"
value={values.socials.twitter || ''}
onChange={handleChange}
placeholder="Twitter profile URL"
className="h-9 bg-white"
/>
</div>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#1877F2]/10 rounded-md">
<SiFacebook size={16} color="#1877F2"/>
</div>
<Input
id="socials.facebook"
name="socials.facebook"
value={values.socials.facebook || ''}
onChange={handleChange}
placeholder="Facebook profile URL"
className="h-9 bg-white"
/>
</div>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#E4405F]/10 rounded-md">
<SiInstagram size={16} color="#E4405F"/>
</div>
<Input
id="socials.instagram"
name="socials.instagram"
value={values.socials.instagram || ''}
onChange={handleChange}
placeholder="Instagram profile URL"
className="h-9 bg-white"
/>
</div>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#FF0000]/10 rounded-md">
<SiYoutube size={16} color="#FF0000"/>
</div>
<Input
id="socials.youtube"
name="socials.youtube"
value={values.socials.youtube || ''}
onChange={handleChange}
placeholder="YouTube channel URL"
className="h-9 bg-white"
/>
</div>
</div>
</div>
</div>
</div>
<div className="w-full space-y-6">
<div>
<Label className="text-lg font-semibold">Custom Links</Label>
<div className="space-y-3 bg-gray-50/50 p-4 rounded-lg nice-shadow mt-2">
{Object.entries(values.links).map(([linkKey, linkValue], index) => (
<div key={index} className="flex gap-3 items-center">
<div className="w-8 h-8 flex items-center justify-center bg-gray-200/50 rounded-md text-xs font-medium text-gray-600">
{index + 1}
</div>
<div className="flex-1 flex gap-2">
<Input
placeholder="Label"
value={linkKey}
className="h-9 w-1/3 bg-white"
onChange={(e) => {
const newLinks = { ...values.links };
delete newLinks[linkKey];
newLinks[e.target.value] = linkValue;
setFieldValue('links', newLinks);
}}
/>
<Input
placeholder="URL"
value={linkValue}
className="h-9 flex-1 bg-white"
onChange={(e) => {
const newLinks = { ...values.links };
newLinks[linkKey] = e.target.value;
setFieldValue('links', newLinks);
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newLinks = { ...values.links };
delete newLinks[linkKey];
setFieldValue('links', newLinks);
}}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
</div>
))}
{Object.keys(values.links).length < 3 && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => {
const newLinks = { ...values.links };
newLinks[`Link ${Object.keys(newLinks).length + 1}`] = '';
setFieldValue('links', newLinks);
}}
>
<Plus className="h-4 w-4 mr-2" />
Add Link
</Button>
)}
<p className="text-xs text-gray-500 mt-2">
Add up to 3 custom links that will appear on your organization's profile
</p>
</div>
</div>
</div>
</div>
<div className="flex flex-row-reverse mt-3 mx-5 mb-5">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</Form>
)}
</Formik>
</div>
)
}

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -96,3 +96,8 @@ export function getOrgThumbnailMediaDirectory(orgUUID: string, fileId: string) {
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/thumbnails/${fileId}` let uri = `${getMediaUrl()}content/orgs/${orgUUID}/thumbnails/${fileId}`
return uri return uri
} }
export function getOrgPreviewMediaDirectory(orgUUID: string, fileId: string) {
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/previews/${fileId}`
return uri
}

View file

@ -54,3 +54,15 @@ export async function uploadOrganizationThumbnail(
const res = await errorHandling(result) const res = await errorHandling(result)
return res return res
} }
export const uploadOrganizationPreview = async (orgId: string, file: File, access_token: string) => {
const formData = new FormData();
formData.append('preview_file', file);
const result: any = await fetch(
`${getAPIUrl()}orgs/` + orgId + '/preview',
RequestBodyFormWithAuthHeader('PUT', formData, null, access_token)
)
const res = await errorHandling(result)
return res
};