diff --git a/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py b/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py new file mode 100644 index 00000000..62370b4e --- /dev/null +++ b/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py @@ -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 ### diff --git a/apps/api/src/db/organizations.py b/apps/api/src/db/organizations.py index f5702103..81dbfea8 100644 --- a/apps/api/src/db/organizations.py +++ b/apps/api/src/db/organizations.py @@ -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 diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index 5c171f62..e6025829 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -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( diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 13c084ed..447e26b1 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -36,7 +36,7 @@ from src.db.organizations import ( ) from fastapi import HTTPException, UploadFile, status, Request -from src.services.orgs.uploads import upload_org_logo, upload_org_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 ## diff --git a/apps/api/src/services/orgs/uploads.py b/apps/api/src/services/orgs/uploads.py index 813b625d..7c393d53 100644 --- a/apps/api/src/services/orgs/uploads.py +++ b/apps/api/src/services/orgs/uploads.py @@ -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 \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx index 6054523b..44a886d5 100644 --- a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx @@ -1,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 ( + +
+
+ +
{tab.label}
+
+
+ + ) +} + 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 (
-
+
-
+
{H1Label} @@ -41,25 +82,15 @@ function OrgPage({ params }: { params: OrgParams }) {
-
- -
-
- -
General
-
-
- +
+ {SETTING_TABS.map((tab) => ( + + ))}
@@ -70,6 +101,8 @@ function OrgPage({ params }: { params: OrgParams }) { transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} > {params.subpage == 'general' ? : ''} + {params.subpage == 'previews' ? : ''} + {params.subpage == 'socials' ? : ''}
) diff --git a/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx b/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx index 032d4e69..41b09c65 100644 --- a/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx @@ -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(null); - const [localThumbnail, setLocalThumbnail] = useState(null); - const handleFileChange = async (event: React.ChangeEvent) => { - 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) => { - 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 ( -
- +
{ setTimeout(() => { setSubmitting(false) @@ -108,129 +114,145 @@ function OrgEditGeneral() { }, 400) }} > - {({ isSubmitting }) => ( + {({ isSubmitting, values, handleChange, errors, touched, setFieldValue }) => (
-
-
- - - - - - - - - - - - - +
+
+

+ Organization Settings +

+

+ Manage your organization's profile and settings +

-
- - - Logo - Thumbnail - - -
-
-
-
-
-
- - -
-
-
- -

Accepts PNG, JPG

-
+
+
+
+
+ + + {touched.name && errors.name && ( +

{errors.name}

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

Accepts PNG, JPG

-
+ +
+ + + {touched.description && errors.description && ( +

{errors.description}

+ )}
- - + +
+ + + {touched.label && errors.label && ( +

{errors.label}

+ )} +
+ +
+ +