diff --git a/apps/api/migrations/versions/040ccb1d456e_add_thumbnail_image_to_orgs.py b/apps/api/migrations/versions/040ccb1d456e_add_thumbnail_image_to_orgs.py
new file mode 100644
index 00000000..14895852
--- /dev/null
+++ b/apps/api/migrations/versions/040ccb1d456e_add_thumbnail_image_to_orgs.py
@@ -0,0 +1,30 @@
+"""Add thumbnail image to orgs
+
+Revision ID: 040ccb1d456e
+Revises: 83b6d9d6f57a
+Create Date: 2024-09-27 10:23:50.508031
+
+"""
+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 = '040ccb1d456e'
+down_revision: Union[str, None] = '83b6d9d6f57a'
+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('thumbnail_image', sa.String(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('organization', 'thumbnail_image')
+ # ### end Alembic commands ###
diff --git a/apps/api/src/db/organizations.py b/apps/api/src/db/organizations.py
index 0edf1200..f5702103 100644
--- a/apps/api/src/db/organizations.py
+++ b/apps/api/src/db/organizations.py
@@ -12,6 +12,7 @@ class OrganizationBase(SQLModel):
slug: str
email: str
logo_image: Optional[str]
+ thumbnail_image: Optional[str]
class Organization(OrganizationBase, table=True):
diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py
index 748b5fa6..5c171f62 100644
--- a/apps/api/src/routers/orgs.py
+++ b/apps/api/src/routers/orgs.py
@@ -38,6 +38,7 @@ from src.services.orgs.orgs import (
update_org,
update_org_logo,
update_org_signup_mechanism,
+ update_org_thumbnail,
)
@@ -303,7 +304,7 @@ async def api_update_org_logo(
db_session: Session = Depends(get_db_session),
):
"""
- Get single Org by Slug
+ Update org logo
"""
return await update_org_logo(
request=request,
@@ -314,6 +315,26 @@ async def api_update_org_logo(
)
+@router.put("/{org_id}/thumbnail")
+async def api_update_org_thumbnail(
+ request: Request,
+ org_id: str,
+ thumbnail_file: UploadFile,
+ current_user: PublicUser = Depends(get_current_user),
+ db_session: Session = Depends(get_db_session),
+):
+ """
+ Update org thumbnail
+ """
+ return await update_org_thumbnail(
+ request=request,
+ thumbnail_file=thumbnail_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(
request: Request,
diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py
index 23d1b4e6..c7e92000 100644
--- a/apps/api/src/services/orgs/orgs.py
+++ b/apps/api/src/services/orgs/orgs.py
@@ -34,9 +34,10 @@ from src.db.organizations import (
OrganizationRead,
OrganizationUpdate,
)
-from src.services.orgs.logos import upload_org_logo
from fastapi import HTTPException, UploadFile, status, Request
+from src.services.orgs.uploads import upload_org_logo, upload_org_thumbnail
+
async def get_organization(
request: Request,
@@ -421,6 +422,42 @@ async def update_org_logo(
return {"detail": "Logo updated"}
+async def update_org_thumbnail(
+ request: Request,
+ thumbnail_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_thumbnail(thumbnail_file, org.org_uuid)
+
+ # Update org
+ org.thumbnail_image = name_in_disk
+
+ # Complete the org object
+ org.update_date = str(datetime.now())
+
+ db_session.add(org)
+ db_session.commit()
+ db_session.refresh(org)
+
+ return {"detail": "Thumbnail updated"}
+
async def delete_org(
request: Request,
diff --git a/apps/api/src/services/orgs/logos.py b/apps/api/src/services/orgs/uploads.py
similarity index 54%
rename from apps/api/src/services/orgs/logos.py
rename to apps/api/src/services/orgs/uploads.py
index 9bb173d3..813b625d 100644
--- a/apps/api/src/services/orgs/logos.py
+++ b/apps/api/src/services/orgs/uploads.py
@@ -16,3 +16,18 @@ async def upload_org_logo(logo_file, org_uuid):
)
return name_in_disk
+
+
+async def upload_org_thumbnail(thumbnail_file, org_uuid):
+ contents = thumbnail_file.file.read()
+ name_in_disk = f"{uuid4()}.{thumbnail_file.filename.split('.')[-1]}"
+
+ await upload_content(
+ "thumbnails",
+ "orgs",
+ org_uuid,
+ contents,
+ name_in_disk,
+ )
+
+ return name_in_disk
diff --git a/apps/web/app/api/sitemap/route.ts b/apps/web/app/api/sitemap/route.ts
new file mode 100644
index 00000000..82a75aba
--- /dev/null
+++ b/apps/web/app/api/sitemap/route.ts
@@ -0,0 +1,72 @@
+import { getUriWithOrg } from '@services/config/config';
+import { getOrgCourses } from '@services/courses/courses';
+import { getOrganizationContextInfo } from '@services/organizations/orgs';
+import { getOrgCollections } from '@services/courses/collections';
+import { NextRequest, NextResponse } from 'next/server';
+
+
+export async function GET(request: NextRequest) {
+ const orgSlug = request.headers.get('X-Sitemap-Orgslug');
+
+ if (!orgSlug) {
+ return NextResponse.json({ error: 'Missing X-Sitemap-Orgslug header' }, { status: 400 });
+ }
+
+ const orgInfo = await getOrganizationContextInfo(orgSlug, null);
+ const courses = await getOrgCourses(orgSlug, null);
+ const collections = await getOrgCollections(orgInfo.id);
+
+ const host = request.headers.get('host');
+ if (!host) {
+ return NextResponse.json({ error: 'Missing host header' }, { status: 400 });
+ }
+
+ const baseUrl = getUriWithOrg(orgSlug, '/');
+
+ const sitemapUrls: SitemapUrl[] = [
+ { loc: baseUrl, priority: 1.0, changefreq: 'daily' },
+ { loc: `${baseUrl}collections`, priority: 0.9, changefreq: 'weekly' },
+ { loc: `${baseUrl}courses`, priority: 0.9, changefreq: 'weekly' },
+ // Courses
+ ...courses.map((course: { course_uuid: string }) => ({
+ loc: `${baseUrl}course/${course.course_uuid.replace('course_', '')}`,
+ priority: 0.7,
+ changefreq: 'weekly'
+ })),
+ // Collections
+ ...collections.map((collection: { collection_uuid: string }) => ({
+ loc: `${baseUrl}collections/${collection.collection_uuid.replace('collection_', '')}`,
+ priority: 0.6,
+ changefreq: 'weekly'
+ }))
+ ];
+
+ const sitemap = generateSitemap(baseUrl, sitemapUrls);
+
+ return new NextResponse(sitemap, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ });
+}
+
+interface SitemapUrl {
+ loc: string;
+ priority: number;
+ changefreq: string;
+ }
+
+ function generateSitemap(baseUrl: string, urls: SitemapUrl[]): string {
+ const urlEntries = urls.map(({ loc, priority, changefreq }) => `
+
+ ${loc}
+ ${priority.toFixed(1)}
+ ${changefreq}
+ `).join('');
+
+ return `
+
+ ${urlEntries}
+ `;
+ }
+
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
index 9ff96216..b69b3cc7 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
@@ -11,6 +11,7 @@ import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder'
import { nextAuthOptions } from 'app/auth/options'
import { getServerSession } from 'next-auth'
import { getOrgCollections } from '@services/courses/collections'
+import { getOrgThumbnailMediaDirectory } from '@services/media/media'
type MetadataProps = {
params: { orgslug: string; courseid: string }
@@ -44,6 +45,14 @@ export async function generateMetadata({
title: `Collections — ${org.name}`,
description: `Collections of courses from ${org.name}`,
type: 'website',
+ images: [
+ {
+ url: getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image),
+ width: 800,
+ height: 600,
+ alt: org.name,
+ },
+ ],
},
}
}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/courses/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/courses/page.tsx
index a339cd02..477b8ba6 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/courses/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/courses/page.tsx
@@ -5,6 +5,7 @@ import { getOrganizationContextInfo } from '@services/organizations/orgs'
import { nextAuthOptions } from 'app/auth/options'
import { getServerSession } from 'next-auth'
import { getOrgCourses } from '@services/courses/courses'
+import { getOrgThumbnailMediaDirectory } from '@services/media/media'
type MetadataProps = {
params: { orgslug: string }
@@ -39,6 +40,14 @@ export async function generateMetadata({
title: 'Courses — ' + org.name,
description: org.description,
type: 'website',
+ images: [
+ {
+ url: getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image),
+ width: 800,
+ height: 600,
+ alt: org.name,
+ },
+ ],
},
}
}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx
index 5718bb98..429b35e2 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx
@@ -15,6 +15,7 @@ import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder'
import { getOrgCollections } from '@services/courses/collections'
import { getServerSession } from 'next-auth'
import { nextAuthOptions } from 'app/auth/options'
+import { getOrgThumbnailMediaDirectory } from '@services/media/media'
type MetadataProps = {
params: { orgslug: string }
@@ -48,6 +49,14 @@ export async function generateMetadata({
title: `Home — ${org.name}`,
description: org.description,
type: 'website',
+ images: [
+ {
+ url: getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image),
+ width: 800,
+ height: 600,
+ alt: org.name,
+ },
+ ],
},
}
}
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 ee6a9479..d748809b 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
@@ -3,7 +3,7 @@ import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import { getUriWithOrg } from '@services/config/config'
import { Info } from 'lucide-react'
import Link from 'next/link'
-import React from 'react'
+import React, { useEffect } from 'react'
import { motion } from 'framer-motion'
import OrgEditGeneral from '@components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral'
@@ -13,14 +13,31 @@ export type OrgParams = {
}
function OrgPage({ params }: { params: OrgParams }) {
+ const [H1Label, setH1Label] = React.useState('')
+ const [H2Label, setH2Label] = React.useState('')
+
+ function handleLabels() {
+ if (params.subpage == 'general') {
+ setH1Label('General')
+ setH2Label('Manage your organization settings')
+ }
+ }
+
+ useEffect(() => {
+ handleLabels()
+ }, [params.subpage, params])
+
return (
-
-
-
- Organization Settings
+
+
+
+ {H1Label}
+
+
+ {H2Label}{' '}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
deleted file mode 100644
index ea12976d..00000000
--- a/apps/web/app/page.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-'use client'
-import { motion } from 'framer-motion'
-import styled from 'styled-components'
-import learnhouseBigIcon from 'public/learnhouse_bigicon.png'
-import Image from 'next/legacy/image'
-import Link from 'next/link'
-
-export default function Home() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- See Organizations
-
-
-
-
- Login
-
-
-
-
- )
-}
-
-const OrgsButton = styled.button`
- background: #151515;
- border: 1px solid #e5e5e50a;
- box-sizing: border-box;
- border-radius: 4px;
- padding: 10px 20px;
- color: white;
- font-size: 16px;
- line-height: 24px;
- margin: 0 10px;
- margin: auto;
- cursor: pointer;
- font-family: 'DM Sans';
- font-weight: 500;
- border-radius: 12px;
- -webkit-transition: all 0.2s ease-in-out;
- transition: all 0.2s ease-in-out;
- &:hover {
- background: #191919;
- }
-`
-
-const HomePage = styled.div`
- display: flex;
- flex-direction: column;
- background: linear-gradient(131.61deg, #202020 7.15%, #000000 90.96%);
- justify-content: center;
- align-items: center;
- height: 100vh;
- width: 100vw;
- min-height: 100vh;
- text-align: center;
- img {
- width: 60px;
- }
-`
diff --git a/apps/web/components.json b/apps/web/components.json
new file mode 100644
index 00000000..7a2c084c
--- /dev/null
+++ b/apps/web/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "styles/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
\ No newline at end of file
diff --git a/apps/web/components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral.tsx b/apps/web/components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral.tsx
index 118eec41..ef26f827 100644
--- a/apps/web/components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral.tsx
+++ b/apps/web/components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral.tsx
@@ -4,12 +4,16 @@ import { Field, Form, Formik } from 'formik'
import {
updateOrganization,
uploadOrganizationLogo,
+ uploadOrganizationThumbnail,
} from '@services/settings/org'
-import { UploadCloud } from 'lucide-react'
+import { UploadCloud, Info, Check, FileWarning } 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';
interface OrganizationValues {
name: string
@@ -17,32 +21,54 @@ interface OrganizationValues {
slug: string
logo: string
email: string
+ thumbnail: string
}
-function OrgEditGeneral(props: any) {
- const [selectedFile, setSelectedFile] = useState
(null)
- const router = useRouter();
- const session = useLHSession() as any;
- const access_token = session?.data?.tokens?.access_token;
+function OrgEditGeneral() {
+ 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 = (event: React.ChangeEvent) => {
+ const handleFileChange = async (event: React.ChangeEvent) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
- setSelectedFile(file)
+ 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 uploadLogo = async () => {
- if (selectedFile) {
- let org_id = org.id
- await uploadOrganizationLogo(org_id, selectedFile, access_token)
- setSelectedFile(null) // Reset the selected file
- await revalidateTags(['organizations'], org.slug)
- router.refresh()
+ 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 = {
name: org?.name,
@@ -50,21 +76,25 @@ function OrgEditGeneral(props: any) {
slug: org?.slug,
logo: org?.logo,
email: org?.email,
+ thumbnail: org?.thumbnail,
}
const updateOrg = async (values: OrganizationValues) => {
- let org_id = org.id
- await updateOrganization(org_id, values, access_token)
-
- // Mutate the org
- await revalidateTags(['organizations'], org.slug)
- router.refresh()
+ 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 });
+ } catch (err) {
+ toast.error('Failed to update organization', { id: loadingToast });
+ }
}
- useEffect(() => { }, [org])
+ useEffect(() => {}, [org])
return (
+
{({ isSubmitting }) => (