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 pydantic import BaseModel
from sqlmodel import Field, SQLModel
from sqlmodel import Field, SQLModel, JSON, Column
from src.db.roles import RoleRead
from src.db.organization_config import OrganizationConfig
@ -9,10 +9,16 @@ from src.db.organization_config import OrganizationConfig
class OrganizationBase(SQLModel):
name: str
description: Optional[str]
slug: str
email: str
about: Optional[str]
socials: Optional[dict] = Field(default={}, sa_column=Column(JSON))
links: Optional[dict] = Field(default={}, sa_column=Column(JSON))
logo_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):
@ -26,9 +32,19 @@ class OrganizationWithConfig(BaseModel):
config: OrganizationConfig
class OrganizationUpdate(OrganizationBase):
pass
class OrganizationUpdate(SQLModel):
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):
pass

View file

@ -37,6 +37,7 @@ from src.services.orgs.orgs import (
get_orgs_by_user_admin,
update_org,
update_org_logo,
update_org_preview,
update_org_signup_mechanism,
update_org_thumbnail,
)
@ -334,6 +335,25 @@ async def api_update_org_thumbnail(
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}")
async def api_user_orgs(

View file

@ -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_thumbnail
from src.services.orgs.uploads import upload_org_logo, upload_org_preview, upload_org_thumbnail
async def get_organization(
@ -174,7 +174,7 @@ async def create_org(
storage=StorageOrgConfig(enabled=True, limit=0),
ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"),
assignments=AssignmentOrgConfig(enabled=True, limit=0),
payments=PaymentOrgConfig(enabled=True, stripe_key=""),
payments=PaymentOrgConfig(enabled=True),
discussions=DiscussionOrgConfig(enabled=True, limit=0),
analytics=AnalyticsOrgConfig(enabled=True, limit=0),
collaboration=CollaborationOrgConfig(enabled=True, limit=0),
@ -458,6 +458,31 @@ async def update_org_thumbnail(
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(
request: Request,
@ -675,6 +700,19 @@ async def get_org_join_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 ##

View file

@ -31,3 +31,18 @@ async def upload_org_thumbnail(thumbnail_file, org_uuid):
)
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'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
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 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'
export type OrgParams = {
subpage: 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 }) {
const [H1Label, setH1Label] = React.useState('')
const [H2Label, setH2Label] = React.useState('')
@ -20,6 +55,12 @@ function OrgPage({ params }: { params: OrgParams }) {
if (params.subpage == 'general') {
setH1Label('General')
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 (
<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>
<div className="my-2 py-3">
<div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1">
<div className="pt-3 flex font-bold text-4xl tracking-tighter">
{H1Label}
@ -41,25 +82,15 @@ function OrgPage({ params }: { params: OrgParams }) {
</div>
</div>
</div>
<div className="flex space-x-5 font-black text-sm">
<Link
href={
getUriWithOrg(params.orgslug, '') + `/dash/org/settings/general`
}
>
<div
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 className="flex space-x-0.5 font-black text-sm">
{SETTING_TABS.map((tab) => (
<TabLink
key={tab.id}
tab={tab}
isActive={params.subpage === tab.id}
orgslug={params.orgslug}
/>
))}
</div>
</div>
<div className="h-6"></div>
@ -70,6 +101,8 @@ function OrgPage({ params }: { params: OrgParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
>
{params.subpage == 'general' ? <OrgEditGeneral /> : ''}
{params.subpage == 'previews' ? <OrgEditImages /> : ''}
{params.subpage == 'socials' ? <OrgEditSocials /> : ''}
</motion.div>
</div>
)

View file

@ -1,106 +1,112 @@
'use client'
import React, { useEffect, useState } from 'react'
import { Field, Form, Formik } from 'formik'
import React, { useState } from 'react'
import { Form, Formik } from 'formik'
import * as Yup from 'yup'
import {
updateOrganization,
uploadOrganizationLogo,
uploadOrganizationThumbnail,
} from '@services/settings/org'
import { UploadCloud, Info } from 'lucide-react'
import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media'
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs"
import { Toaster, toast } from 'react-hot-toast';
import { constructAcceptValue } from '@/lib/constants';
import { toast } from 'react-hot-toast'
import { Input } from "@components/ui/input"
import { Textarea } from "@components/ui/textarea"
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 {
name: string
description: string
slug: string
logo: string
email: string
thumbnail: string
about: string
label: string
explore: boolean
}
function OrgEditGeneral() {
const OrgEditGeneral: React.FC = () => {
const router = useRouter()
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
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>) => {
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 = {
const initialValues: OrganizationValues = {
name: org?.name,
description: org?.description,
slug: org?.slug,
logo: org?.logo,
email: org?.email,
thumbnail: org?.thumbnail,
description: org?.description || '',
about: org?.about || '',
label: org?.label || '',
explore: org?.explore ?? true,
}
const updateOrg = async (values: OrganizationValues) => {
const loadingToast = toast.loading('Updating organization...');
const loadingToast = toast.loading('Updating organization...')
try {
await updateOrganization(org.id, values, access_token)
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) {
toast.error('Failed to update organization', { id: loadingToast });
toast.error('Failed to update organization', { id: loadingToast })
}
}
useEffect(() => {}, [org])
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">
<Toaster />
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow ">
<Formik
enableReinitialize
initialValues={orgValues}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false)
@ -108,129 +114,145 @@ function OrgEditGeneral() {
}, 400)
}}
>
{({ isSubmitting }) => (
{({ isSubmitting, values, handleChange, errors, touched, setFieldValue }) => (
<Form>
<div className="flex flex-col lg:flex-row lg:space-x-8">
<div className="w-full lg:w-1/2 mb-8 lg:mb-0">
<label className="block mb-2 font-bold" htmlFor="name">
Name
</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"
<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">
Organization Settings
</h1>
<h2 className="text-gray-500 text-md">
Manage your organization's profile and settings
</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 className="space-y-4">
<div>
<Label htmlFor="name">
Organization Name
<span className="text-gray-500 text-sm ml-2">
({60 - (values.name?.length || 0)} characters left)
</span>
</Label>
<Input
id="name"
name="name"
value={values.name}
onChange={handleChange}
placeholder="Organization Name"
maxLength={60}
/>
{touched.name && errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</div>
<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"
<div>
<Label htmlFor="description">
Short Description
<span className="text-gray-500 text-sm ml-2">
({100 - (values.description?.length || 0)} characters left)
</span>
</Label>
<Input
id="description"
name="description"
value={values.description}
onChange={handleChange}
placeholder="Brief description of your organization"
maxLength={100}
/>
{touched.description && errors.description && (
<p className="text-red-500 text-sm mt-1">{errors.description}</p>
)}
</div>
<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"
<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>
<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"
<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"
/>
<button
<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="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"
className="bg-black text-white hover:bg-black/90"
>
Submit
</button>
</div>
<div className="w-full lg:w-1/2">
<Tabs defaultValue="logo" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6 sm:mb-10">
<TabsTrigger value="logo">Logo</TabsTrigger>
<TabsTrigger value="thumbnail">Thumbnail</TabsTrigger>
</TabsList>
<TabsContent value="logo">
<div className="flex flex-col space-y-3">
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10">
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10">
<div
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white"
style={{ backgroundImage: `url(${localLogo || getOrgLogoMediaDirectory(org?.org_uuid, org?.logo_image)})` }}
/>
</div>
<div className="flex justify-center items-center">
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
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>
</TabsContent>
<TabsContent value="thumbnail">
<div className="flex flex-col space-y-3">
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10">
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10">
<div
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white"
style={{ backgroundImage: `url(${localThumbnail || getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image)})` }}
/>
</div>
<div className="flex justify-center items-center">
<input
type="file"
accept={SUPPORTED_FILES}
id="thumbnailInput"
style={{ display: 'none' }}
onChange={handleThumbnailChange}
/>
<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>
</TabsContent>
</Tabs>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</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}`
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)
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
};