Merge pull request #415 from learnhouse/feat/explore

Explore Marketplace features
This commit is contained in:
Badr B. 2025-01-25 13:05:08 +01:00 committed by GitHub
commit 8729751830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2250 additions and 524 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

@ -1,8 +1,10 @@
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session
from src.core.events.database import get_db_session
from src.db.organization_config import OrganizationConfigBase
from src.services.explore.explore import get_course_for_explore, get_courses_for_an_org_explore, get_org_for_explore, get_orgs_for_explore, search_orgs_for_explore
from src.services.orgs.orgs import update_org_with_config_no_auth
router = APIRouter()
@ -14,6 +16,49 @@ def check_internal_cloud_key(request: Request):
):
raise HTTPException(status_code=403, detail="Unauthorized")
@router.get("/explore/orgs")
async def api_get_orgs_for_explore(
request: Request,
page: int = 1,
limit: int = 10,
label: str = "",
salt: str = "",
db_session: Session = Depends(get_db_session),
):
return await get_orgs_for_explore(request, db_session, page, limit, label, salt)
@router.get("/explore/orgs/search")
async def api_search_orgs_for_explore(
request: Request,
search_query: str,
label: Optional[str] = None,
db_session: Session = Depends(get_db_session),
):
return await search_orgs_for_explore(request, db_session, search_query, label)
@router.get("/explore/orgs/{org_uuid}/courses")
async def api_get_courses_for_explore(
request: Request,
org_uuid: str,
db_session: Session = Depends(get_db_session),
):
return await get_courses_for_an_org_explore(request, db_session, org_uuid)
@router.get("/explore/courses/{course_id}")
async def api_get_course_for_explore(
request: Request,
course_id: str,
db_session: Session = Depends(get_db_session),
):
return await get_course_for_explore(request, course_id, db_session)
@router.get("/explore/orgs/{org_slug}")
async def api_get_org_for_explore(
request: Request,
org_slug: str,
db_session: Session = Depends(get_db_session),
):
return await get_org_for_explore(request, org_slug, db_session)
@router.put("/update_org_config")
async def update_org_Config(

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

@ -47,15 +47,23 @@ async def get_collection(
# get courses in collection
statement_all = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.where(CollectionCourse.org_id == collection.org_id)
.distinct(Course.id)
.join(CollectionCourse)
.where(
CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == collection.org_id
)
.distinct()
)
statement_public = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.where(CollectionCourse.org_id == collection.org_id, Course.public == True)
.join(CollectionCourse)
.where(
CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == collection.org_id,
Course.public == True
)
.distinct()
)
if current_user.user_uuid == "user_anonymous":
@ -63,7 +71,7 @@ async def get_collection(
else:
statement = statement_all
courses = db_session.exec(statement).all()
courses = list(db_session.exec(statement).all())
collection = CollectionRead(**collection.model_dump(), courses=courses)
@ -110,10 +118,11 @@ async def create_collection(
# Get courses once again
statement = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.distinct(Course.id)
.join(CollectionCourse)
.where(CollectionCourse.collection_id == collection.id)
.distinct()
)
courses = db_session.exec(statement).all()
courses = list(db_session.exec(statement).all())
collection = CollectionRead(**collection.model_dump(), courses=courses)
@ -183,12 +192,11 @@ async def update_collection(
# Get courses once again
statement = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.where(Course.org_id == collection.org_id)
.distinct(Course.id)
.join(CollectionCourse)
.where(CollectionCourse.collection_id == collection.id)
.distinct()
)
courses = db_session.exec(statement).all()
courses = list(db_session.exec(statement).all())
collection = CollectionRead(**collection.model_dump(), courses=courses)
@ -255,14 +263,22 @@ async def get_collections(
for collection in collections:
statement_all = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.where(CollectionCourse.org_id == collection.org_id)
.distinct(Course.id)
.join(CollectionCourse)
.where(
CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == collection.org_id
)
.distinct()
)
statement_public = (
select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id)
.where(CollectionCourse.org_id == org_id, Course.public == True)
.join(CollectionCourse)
.where(
CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == org_id,
Course.public == True
)
.distinct()
)
if current_user.id == 0:
statement = statement_public

View file

@ -0,0 +1,165 @@
from typing import Optional
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from sqlalchemy import text
from src.db.courses.courses import Course, CourseRead
from src.db.organizations import Organization, OrganizationRead
def _get_sort_expression(salt: str):
"""Helper function to create consistent sort expression"""
if not salt:
return Organization.name
# Create a deterministic ordering using md5(salt + id)
return text(
f"md5('{salt}' || id)"
)
async def get_orgs_for_explore(
request: Request,
db_session: Session,
page: int = 1,
limit: int = 10,
label: str = "",
salt: str = "",
) -> list[OrganizationRead]:
statement = (
select(Organization)
.where(
Organization.explore == True,
)
)
# Add label filter if provided
if label:
statement = statement.where(Organization.label == label) #type: ignore
# Add deterministic ordering based on salt
statement = statement.order_by(_get_sort_expression(salt))
# Add pagination
statement = (
statement
.offset((page - 1) * limit)
.limit(limit)
)
result = db_session.exec(statement)
orgs = result.all()
return [OrganizationRead.model_validate(org) for org in orgs]
async def get_courses_for_an_org_explore(
request: Request,
db_session: Session,
org_uuid: str,
) -> list[CourseRead]:
statement = select(Organization).where(Organization.org_uuid == org_uuid)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
statement = select(Course).where(Course.org_id == org.id, Course.public == True)
result = db_session.exec(statement)
courses = result.all()
courses_list = []
for course in courses:
courses_list.append(course)
return courses_list
async def get_course_for_explore(
request: Request,
course_id: str,
db_session: Session,
) -> CourseRead:
statement = select(Course).where(Course.id == course_id)
result = db_session.exec(statement)
course = result.first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
return CourseRead.model_validate(course)
async def search_orgs_for_explore(
request: Request,
db_session: Session,
search_query: str,
label: Optional[str] = None,
page: int = 1,
limit: int = 10,
salt: str = "",
) -> list[OrganizationRead]:
# Create a combined search vector
search_terms = search_query.split()
search_conditions = []
for term in search_terms:
term_pattern = f"%{term}%"
search_conditions.append(
(Organization.name.ilike(term_pattern)) | #type: ignore
(Organization.about.ilike(term_pattern)) | #type: ignore
(Organization.description.ilike(term_pattern)) | #type: ignore
(Organization.label.ilike(term_pattern)) #type: ignore
)
statement = (
select(Organization)
.where(Organization.explore == True)
)
if label and label != "all":
statement = statement.where(Organization.label == label) #type: ignore
if search_conditions:
statement = statement.where(*search_conditions)
# Add deterministic ordering based on salt
statement = statement.order_by(_get_sort_expression(salt))
# Add pagination
statement = (
statement
.offset((page - 1) * limit)
.limit(limit)
)
result = db_session.exec(statement)
orgs = result.all()
return [OrganizationRead.model_validate(org) for org in orgs]
async def get_org_for_explore(
request: Request,
org_slug: str,
db_session: Session,
) -> OrganizationRead:
statement = select(Organization).where(Organization.slug == org_slug)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
return OrganizationRead.model_validate(org)

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

@ -39,7 +39,9 @@ def send_password_reset_email(
<html>
<body>
<p>Hello {user.username}</p>
<p>Click <a href="https://{organization.slug}.learnhouse.io/reset?email={email}&resetCode={generated_reset_code}">here</a> to reset your password.</p>
<p>You have requested to reset your password.</p>
<p>Here is your reset code: {generated_reset_code}</p>
<p>Click <a href="https://{organization.slug}.learnhouse.io/reset?orgslug={organization.slug}&email={email}&resetCode={generated_reset_code}">here</a> to reset your password.</p>
</body>
</html>
""",

View file

@ -11,7 +11,7 @@ import * as Form from '@radix-ui/react-form'
import { getOrgLogoMediaDirectory } from '@services/media/media'
import { AlertTriangle, Info } from 'lucide-react'
import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config'
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import { useOrg } from '@components/Contexts/OrgContext'
import { useRouter, useSearchParams } from 'next/navigation'
import { useFormik } from 'formik'
@ -139,9 +139,14 @@ function ResetPasswordClient() {
</div>
)}
{message && (
<div className="flex justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<Info size={18} />
<div className="font-bold text-sm">{message}</div>
<div className="flex flex-col gap-2">
<div className="flex justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<Info size={18} />
<div className="font-bold text-sm">{message}</div>
</div>
<Link href={getUriWithoutOrg('/login?orgslug=' + org.slug)} className="text-center text-sm text-blue-600 hover:text-blue-800">
Please login again with your new password
</Link>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>

View file

@ -7,17 +7,22 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import { revalidateTags, swrFetcher } from '@services/utils/ts/requests'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { Loader2, Image as ImageIcon } from 'lucide-react'
import { toast } from 'react-hot-toast'
import Image from 'next/image'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
function NewCollection(params: any) {
const org = useOrg() as any
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const orgslug = params.params.orgslug
const [name, setName] = React.useState('')
const [description, setDescription] = React.useState('')
const [selectedCourses, setSelectedCourses] = React.useState([]) as any
const [isSubmitting, setIsSubmitting] = useState(false)
const router = useRouter()
const { data: courses, error: error } = useSWR(
const { data: courses, error: error, isLoading } = useSWR(
`${getAPIUrl()}courses/org_slug/${orgslug}/page/1/limit/10`,
(url) => swrFetcher(url, access_token)
)
@ -32,111 +37,189 @@ function NewCollection(params: any) {
}
const handleDescriptionChange = (
event: React.ChangeEvent<HTMLInputElement>
event: React.ChangeEvent<HTMLTextAreaElement>
) => {
setDescription(event.target.value)
}
const handleSubmit = async (e: any) => {
e.preventDefault()
const collection = {
name: name,
description: description,
courses: selectedCourses,
public: isPublic,
org_id: org.id,
if (!name.trim()) {
toast.error('Please enter a collection name')
return
}
await createCollection(collection, session.data?.tokens?.access_token)
await revalidateTags(['collections'], org.slug)
// reload the page
router.refresh()
// wait for 2s before reloading the page
setTimeout(() => {
if (!description.trim()) {
toast.error('Please enter a description')
return
}
if (selectedCourses.length === 0) {
toast.error('Please select at least one course')
return
}
setIsSubmitting(true)
try {
const collection = {
name: name.trim(),
description: description.trim(),
courses: selectedCourses,
public: isPublic,
org_id: org.id,
}
await createCollection(collection, session.data?.tokens?.access_token)
await revalidateTags(['collections'], org.slug)
toast.success('Collection created successfully!')
router.push(getUriWithOrg(orgslug, '/collections'))
}, 1000)
} catch (error) {
toast.error('Failed to create collection. Please try again.')
} finally {
setIsSubmitting(false)
}
}
if (error) {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-red-500">Failed to load courses. Please try again later.</div>
</div>
)
}
return (
<>
<div className="w-64 m-auto py-20">
<div className="font-bold text-lg mb-4">Add new</div>
<div className="max-w-2xl mx-auto py-12 px-4">
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Create New Collection</h1>
<p className="mt-2 text-sm text-gray-600">
Group your courses together in a collection to make them easier to find and manage.
</p>
</div>
<input
type="text"
placeholder="Name"
value={name}
onChange={handleNameChange}
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-gray-700">Collection Name</span>
<input
type="text"
placeholder="Enter collection name"
value={name}
onChange={handleNameChange}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
maxLength={100}
/>
</label>
<select
onChange={handleVisibilityChange}
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
defaultValue={isPublic}
>
<option value="false">Private Collection</option>
<option value="true">Public Collection </option>
</select>
{!courses ? (
<p className="text-gray-500">Loading...</p>
) : (
<div className="space-y-4 p-3">
<p>Courses</p>
{courses.map((course: any) => (
<div
key={course.course_uuid}
className="flex items-center space-x-2"
<label className="block">
<span className="text-sm font-medium text-gray-700">Visibility</span>
<select
onChange={handleVisibilityChange}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
defaultValue={isPublic}
>
<input
type="checkbox"
id={course.id}
name={course.name}
value={course.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedCourses([...selectedCourses, course.id])
} else {
setSelectedCourses(
selectedCourses.filter(
(course_uuid: any) =>
course_uuid !== course.course_uuid
)
)
}
}}
className="text-blue-500 rounded focus:ring-2 focus:ring-blue-500"
/>
<option value="true">Public Collection - Visible to everyone</option>
<option value="false">Private Collection - Only visible to organization members</option>
</select>
</label>
<label
htmlFor={course.course_uuid}
className="text-sm text-gray-700"
>
{course.name}
</label>
</div>
))}
<label className="block">
<span className="text-sm font-medium text-gray-700">Description</span>
<textarea
placeholder="Enter collection description"
value={description}
onChange={handleDescriptionChange}
rows={4}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
maxLength={500}
/>
</label>
<div className="space-y-2">
<span className="text-sm font-medium text-gray-700">Select Courses</span>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
</div>
) : courses?.length === 0 ? (
<p className="text-sm text-gray-500 py-4">No courses available. Create some courses first.</p>
) : (
<div className="mt-2 border border-gray-200 rounded-lg bg-gray-50">
<div className="max-h-[400px] overflow-y-auto p-4 space-y-3 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400">
{courses?.map((course: any) => (
<label
key={course.id}
className="relative flex items-center p-4 bg-white rounded-md hover:bg-gray-50 transition cursor-pointer gap-4"
>
<input
type="checkbox"
id={course.id}
name={course.name}
value={course.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedCourses([...selectedCourses, course.id])
} else {
setSelectedCourses(
selectedCourses.filter((id: any) => id !== course.id)
)
}
}}
className="h-4 w-4 text-blue-500 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="relative w-24 h-16 rounded-md overflow-hidden bg-gray-100 flex-shrink-0">
{course.thumbnail_image ? (
<img
src={getCourseThumbnailMediaDirectory(org.org_uuid, course.course_uuid, course.thumbnail_image)}
alt={course.name}
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-6 h-6 text-gray-400" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 truncate">{course.name}</h3>
{course.description && (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{course.description}</p>
)}
</div>
</label>
))}
</div>
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200">
<p className="text-xs text-gray-500">
Selected courses: {selectedCourses.length}
</p>
</div>
</div>
)}
</div>
</div>
)}
<input
type="text"
placeholder="Description"
value={description}
onChange={handleDescriptionChange}
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSubmit}
className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Submit
</button>
<div className="flex items-center justify-end space-x-4">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
<span>{isSubmitting ? 'Creating...' : 'Create Collection'}</span>
</button>
</div>
</form>
</div>
</>
</div>
)
}

View file

@ -136,7 +136,7 @@ const CollectionsPage = async (params: any) => {
text="Create a collection to add content"
/>
</p>
<div className="mt-4">
<div className="mt-4 flex justify-center">
<AuthenticatedClientElement
checkMethod="roles"
ressourceType="collections"

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,10 +1,10 @@
'use client'
import React, { useState, useEffect } from 'react'
import React from 'react'
import { motion } from 'framer-motion'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config'
import { CreditCard, Settings, Repeat, BookOpen, Users, DollarSign, Gem } from 'lucide-react'
import { Settings, Users, Gem } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import PaymentsConfigurationPage from '@components/Dashboard/Pages/Payments/PaymentsConfigurationPage'
@ -20,18 +20,37 @@ export type PaymentsParams = {
function PaymentsPage({ params }: { params: PaymentsParams }) {
const session = useLHSession() as any
const org = useOrg() as any
const [selectedSubPage, setSelectedSubPage] = useState(params.subpage || 'general')
const [H1Label, setH1Label] = useState('')
const [H2Label, setH2Label] = useState('')
const subpage = params.subpage || 'customers'
const isPaymentsEnabled = useFeatureFlag({
path: ['features', 'payments', 'enabled'],
defaultValue: false
})
useEffect(() => {
handleLabels()
}, [selectedSubPage])
const getPageTitle = () => {
switch (subpage) {
case 'customers':
return {
h1: 'Customers',
h2: 'View and manage your customer information'
}
case 'paid-products':
return {
h1: 'Paid Products',
h2: 'Manage your paid products and pricing'
}
case 'configuration':
return {
h1: 'Payment Configuration',
h2: 'Set up and manage your payment gateway'
}
default:
return {
h1: 'Payments',
h2: 'Overview of your payment settings and transactions'
}
}
}
if (!isPaymentsEnabled) {
return (
@ -45,66 +64,41 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
)
}
function handleLabels() {
if (selectedSubPage === 'general') {
setH1Label('Payments')
setH2Label('Overview of your payment settings and transactions')
}
if (selectedSubPage === 'configuration') {
setH1Label('Payment Configuration')
setH2Label('Set up and manage your payment gateway')
}
if (selectedSubPage === 'subscriptions') {
setH1Label('Subscriptions')
setH2Label('Manage your subscription plans')
}
if (selectedSubPage === 'paid-products') {
setH1Label('Paid Products')
setH2Label('Manage your paid products and pricing')
}
if (selectedSubPage === 'customers') {
setH1Label('Customers')
setH2Label('View and manage your customer information')
}
}
const { h1, h2 } = getPageTitle()
return (
<div className="h-screen w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<BreadCrumbs type="payments" />
<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}
{h1}
</div>
<div className="flex font-medium text-gray-400 text-md">
{H2Label}{' '}
{h2}
</div>
</div>
</div>
<div className="flex space-x-5 font-black text-sm">
<div className="flex space-x-0.5 font-black text-sm">
<TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/customers')}
icon={<Users size={16} />}
label="Customers"
isActive={selectedSubPage === 'customers'}
onClick={() => setSelectedSubPage('customers')}
isActive={subpage === 'customers'}
/>
<TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/paid-products')}
icon={<Gem size={16} />}
label="Products & Subscriptions"
isActive={selectedSubPage === 'paid-products'}
onClick={() => setSelectedSubPage('paid-products')}
isActive={subpage === 'paid-products'}
/>
<TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/configuration')}
icon={<Settings size={16} />}
label="Configuration"
isActive={selectedSubPage === 'configuration'}
onClick={() => setSelectedSubPage('configuration')}
isActive={subpage === 'configuration'}
/>
</div>
</div>
<div className="h-6"></div>
@ -115,21 +109,18 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="flex-1 overflow-y-auto"
>
{selectedSubPage === 'general' && <div>General</div>}
{selectedSubPage === 'configuration' && <PaymentsConfigurationPage />}
{selectedSubPage === 'paid-products' && <PaymentsProductPage />}
{selectedSubPage === 'customers' && <PaymentsCustomersPage />}
{subpage === 'configuration' && <PaymentsConfigurationPage />}
{subpage === 'paid-products' && <PaymentsProductPage />}
{subpage === 'customers' && <PaymentsCustomersPage />}
</motion.div>
</div>
)
}
const TabLink = ({ href, icon, label, isActive, onClick }: { href: string, icon: React.ReactNode, label: string, isActive: boolean, onClick: () => void }) => (
const TabLink = ({ href, icon, label, isActive }: { href: string, icon: React.ReactNode, label: string, isActive: boolean }) => (
<Link href={href}>
<div
onClick={onClick}
className={`py-2 w-fit text-center border-black transition-all ease-linear ${isActive ? 'border-b-4' : 'opacity-50'
} cursor-pointer`}
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">
{icon}

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"
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 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="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 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>
</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>
<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>
</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>
</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

@ -83,7 +83,7 @@ const PaymentsConfigurationPage: React.FC = () => {
return (
<div>
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl nice-shadow px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-xl text-gray-800">Payments Configuration</h1>
<h2 className="text-gray-500 text-md">Manage your organization payments configuration</h2>

View file

@ -141,7 +141,7 @@ function PaymentsCustomersPage() {
if (!customers) return <div>No customer data available</div>
return (
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl nice-shadow px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-xl text-gray-800">Customers</h1>
<h2 className="text-gray-500 text-md">View and manage your customer information</h2>

View file

@ -1,7 +1,7 @@
'use client';
import { updateProfile } from '@services/settings/profile'
import React, { useEffect } from 'react'
import { Formik, Form, Field } from 'formik'
import { Formik, Form } from 'formik'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import {
ArrowBigUpDash,
@ -9,13 +9,39 @@ import {
FileWarning,
Info,
UploadCloud,
AlertTriangle,
LogOut
} from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar'
import { updateUserAvatar } from '@services/users/users'
import { constructAcceptValue } from '@/lib/constants';
import { constructAcceptValue } from '@/lib/constants'
import * as Yup from 'yup'
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 { toast } from 'react-hot-toast'
import { signOut } from 'next-auth/react'
import { getUriWithoutOrg } from '@services/config/config';
const SUPPORTED_FILES = constructAcceptValue(['image'])
const validationSchema = Yup.object().shape({
email: Yup.string().email('Invalid email').required('Email is required'),
username: Yup.string().required('Username is required'),
first_name: Yup.string().required('First name is required'),
last_name: Yup.string().required('Last name is required'),
bio: Yup.string().max(400, 'Bio must be 400 characters or less'),
})
interface FormValues {
username: string;
first_name: string;
last_name: string;
email: string;
bio: string;
}
function UserEditGeneral() {
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
@ -40,116 +66,231 @@ function UserEditGeneral() {
}
}
const handleEmailChange = async (newEmail: string) => {
toast.success('Profile Updated Successfully', { duration: 4000 })
// Show message about logging in with new email
toast((t) => (
<div className="flex items-center gap-2">
<span>Please login again with your new email: {newEmail}</span>
</div>
), {
duration: 4000,
icon: '📧'
})
// Wait for 4 seconds before signing out
await new Promise(resolve => setTimeout(resolve, 4000))
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
}
useEffect(() => { }, [session, session.data])
return (
<div className="ml-10 mr-10 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">
{session.data.user && (
<Formik
<Formik<FormValues>
enableReinitialize
initialValues={{
username: session.data.user.username,
first_name: session.data.user.first_name,
last_name: session.data.user.last_name,
email: session.data.user.email,
bio: session.data.user.bio,
bio: session.data.user.bio || '',
}}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
const isEmailChanged = values.email !== session.data.user.email
const loadingToast = toast.loading('Updating profile...')
setTimeout(() => {
setSubmitting(false)
updateProfile(values, session.data.user.id, access_token)
.then(() => {
toast.dismiss(loadingToast)
if (isEmailChanged) {
handleEmailChange(values.email)
} else {
toast.success('Profile Updated Successfully')
}
})
.catch(() => {
toast.error('Failed to update profile', { id: loadingToast })
})
}, 400)
}}
>
{({ isSubmitting }) => (
<div className="flex flex-col lg:flex-row gap-8">
<Form className="flex-1 min-w-0">
<div className="space-y-4">
{[
{ label: 'Email', name: 'email', type: 'email' },
{ label: 'Username', name: 'username', type: 'text' },
{ label: 'First Name', name: 'first_name', type: 'text' },
{ label: 'Last Name', name: 'last_name', type: 'text' },
{ label: 'Bio', name: 'bio', type: 'text' },
].map((field) => (
<div key={field.name}>
<label className="block mb-2 font-bold" htmlFor={field.name}>
{field.label}
</label>
<Field
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type={field.type}
name={field.name}
/>
</div>
))}
{({ isSubmitting, values, handleChange, errors, touched }) => (
<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">
Account Settings
</h1>
<h2 className="text-gray-500 text-md">
Manage your personal information and preferences
</h2>
</div>
<button
type="submit"
disabled={isSubmitting}
className="mt-6 px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Submit
</button>
</Form>
<div className="flex-1 min-w-0">
<div className="flex flex-col items-center space-y-4">
<label className="font-bold">Avatar</label>
{error && (
<div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm">
<FileWarning size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{error}</span>
</div>
)}
{success && (
<div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm">
<Check size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{success}</span>
</div>
)}
<div className="w-full max-w-xs bg-gray-50 rounded-xl outline outline-1 outline-gray-200 shadow p-6">
<div className="flex flex-col items-center space-y-4">
{localAvatar ? (
<UserAvatar
border="border-8"
width={100}
avatar_url={URL.createObjectURL(localAvatar)}
/>
) : (
<UserAvatar border="border-8" width={100} />
<div className="flex flex-col lg:flex-row mt-0 mx-5 my-5 gap-8">
{/* Profile Information Section */}
<div className="flex-1 min-w-0 space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
placeholder="Your email address"
/>
{touched.email && errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
{isLoading ? (
<div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center">
<ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span>
{values.email !== session.data.user.email && (
<div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md">
<AlertTriangle size={16} />
<span className="text-sm">You will be logged out after changing your email</span>
</div>
) : (
<>
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleFileChange}
/>
<button
className="font-bold antialiased text-gray text-sm rounded-md px-4 py-2 flex items-center"
onClick={() => document.getElementById('fileInput')?.click()}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Avatar</span>
</button>
</>
)}
</div>
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
value={values.username}
onChange={handleChange}
placeholder="Your username"
/>
{touched.username && errors.username && (
<p className="text-red-500 text-sm mt-1">{errors.username}</p>
)}
</div>
<div>
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
name="first_name"
value={values.first_name}
onChange={handleChange}
placeholder="Your first name"
/>
{touched.first_name && errors.first_name && (
<p className="text-red-500 text-sm mt-1">{errors.first_name}</p>
)}
</div>
<div>
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
name="last_name"
value={values.last_name}
onChange={handleChange}
placeholder="Your last name"
/>
{touched.last_name && errors.last_name && (
<p className="text-red-500 text-sm mt-1">{errors.last_name}</p>
)}
</div>
<div>
<Label htmlFor="bio">
Bio
<span className="text-gray-500 text-sm ml-2">
({400 - (values.bio?.length || 0)} characters left)
</span>
</Label>
<Textarea
id="bio"
name="bio"
value={values.bio}
onChange={handleChange}
placeholder="Tell us about yourself"
className="min-h-[150px]"
maxLength={400}
/>
{touched.bio && errors.bio && (
<p className="text-red-500 text-sm mt-1">{errors.bio}</p>
)}
</div>
</div>
<div className="flex items-center text-xs text-gray-500">
<Info size={13} className="mr-2" />
<p>Recommended size 100x100</p>
{/* Profile Picture Section */}
<div className="lg:w-80 w-full">
<div className="bg-gray-50/50 p-6 rounded-lg nice-shadow h-full">
<div className="flex flex-col items-center space-y-6">
<Label className="font-bold">Profile Picture</Label>
{error && (
<div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm">
<FileWarning size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{error}</span>
</div>
)}
{success && (
<div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm">
<Check size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{success}</span>
</div>
)}
{localAvatar ? (
<UserAvatar
border="border-8"
width={120}
avatar_url={URL.createObjectURL(localAvatar)}
/>
) : (
<UserAvatar border="border-8" width={120} />
)}
{isLoading ? (
<div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center">
<ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span>
</div>
) : (
<>
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleFileChange}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('fileInput')?.click()}
className="w-full"
>
<UploadCloud size={16} className="mr-2" />
Change Avatar
</Button>
</>
)}
<div className="flex items-center text-xs text-gray-500">
<Info size={13} className="mr-2" />
<p>Recommended size 100x100</p>
</div>
</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>
</Form>
)}
</Formik>
)}

View file

@ -1,61 +1,134 @@
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { updatePassword } from '@services/settings/password'
import { Formik, Form, Field } from 'formik'
import { Formik, Form } from 'formik'
import React, { useEffect } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Input } from "@components/ui/input"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { toast } from 'react-hot-toast'
import { signOut } from 'next-auth/react'
import { getUriWithoutOrg } from '@services/config/config'
import * as Yup from 'yup'
const validationSchema = Yup.object().shape({
old_password: Yup.string().required('Current password is required'),
new_password: Yup.string()
.required('New password is required')
.min(8, 'Password must be at least 8 characters'),
})
function UserEditPassword() {
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token;
const updatePasswordUI = async (values: any) => {
let user_id = session.data.user.id
await updatePassword(user_id, values, access_token)
const loadingToast = toast.loading('Updating password...')
try {
let user_id = session.data.user.id
const response = await updatePassword(user_id, values, access_token)
if (response.success) {
toast.dismiss(loadingToast)
// Show success message and notify about logout
toast.success('Password updated successfully', { duration: 4000 })
toast((t) => (
<div className="flex items-center gap-2">
<span>Please login again with your new password</span>
</div>
), {
duration: 4000,
icon: '🔑'
})
// Wait for 4 seconds before signing out
await new Promise(resolve => setTimeout(resolve, 4000))
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
} else {
toast.error(response.data.detail || 'Failed to update password', { id: loadingToast })
}
} catch (error: any) {
const errorMessage = error.data?.detail || 'Failed to update password. Please try again.'
toast.error(errorMessage, { id: loadingToast })
console.error('Password update error:', error)
}
}
useEffect(() => { }, [session])
return (
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-6 py-5">
<Formik
initialValues={{ old_password: '', new_password: '' }}
enableReinitialize
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false)
updatePasswordUI(values)
}, 400)
}}
>
{({ isSubmitting }) => (
<Form className="max-w-md">
<label className="block mb-2 font-bold" htmlFor="old_password">
Old Password
</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="password"
name="old_password"
/>
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
<div className="flex flex-col">
<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">
Change Password
</h1>
<h2 className="text-gray-500 text-md">
Update your password to keep your account secure
</h2>
</div>
<label className="block mb-2 font-bold" htmlFor="new_password">
New Password
</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="password"
name="new_password"
/>
<div className="px-8 py-6">
<Formik
initialValues={{ old_password: '', new_password: '' }}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false)
updatePasswordUI(values)
}, 400)
}}
>
{({ isSubmitting, handleChange, errors, touched }) => (
<Form className="w-full max-w-2xl mx-auto space-y-6">
<div>
<Label htmlFor="old_password">Current Password</Label>
<Input
type="password"
id="old_password"
name="old_password"
onChange={handleChange}
className="mt-1"
/>
{touched.old_password && errors.old_password && (
<p className="text-red-500 text-sm mt-1">{errors.old_password}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Submit
</button>
</Form>
)}
</Formik>
<div>
<Label htmlFor="new_password">New Password</Label>
<Input
type="password"
id="new_password"
name="new_password"
onChange={handleChange}
className="mt-1"
/>
{touched.new_password && errors.new_password && (
<p className="text-red-500 text-sm mt-1">{errors.new_password}</p>
)}
</div>
<div className="flex items-center space-x-2 text-amber-600 bg-amber-50 p-3 rounded-md">
<AlertTriangle size={16} />
<span className="text-sm">You will be logged out after changing your password</span>
</div>
<div className="flex justify-end pt-2">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Updating...' : 'Update Password'}
</Button>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
)
}

View file

@ -23,21 +23,21 @@ type ModalParams = {
const Modal = (params: ModalParams) => {
const getMinHeight = () => {
switch (params.minHeight) {
case 'sm': return 'min-h-[300px]'
case 'md': return 'min-h-[500px]'
case 'lg': return 'min-h-[700px]'
case 'xl': return 'min-h-[900px]'
default: return ''
case 'sm': return 'min-h-[300px] max-h-[90vh]'
case 'md': return 'min-h-[400px] max-h-[90vh]'
case 'lg': return 'min-h-[500px] max-h-[90vh]'
case 'xl': return 'min-h-[600px] max-h-[90vh]'
default: return 'max-h-[90vh]'
}
}
const getMinWidth = () => {
switch (params.minWidth) {
case 'sm': return 'min-w-[600px]'
case 'md': return 'min-w-[800px]'
case 'lg': return 'min-w-[1000px]'
case 'xl': return 'min-w-[1200px]'
default: return ''
case 'sm': return 'w-[95vw] sm:w-[90vw] md:w-[600px]'
case 'md': return 'w-[95vw] sm:w-[90vw] md:w-[800px]'
case 'lg': return 'w-[95vw] sm:w-[90vw] lg:w-[1000px]'
case 'xl': return 'w-[95vw] sm:w-[90vw] xl:w-[1200px]'
default: return 'w-[95vw] sm:w-[90vw]'
}
}
@ -47,7 +47,8 @@ const Modal = (params: ModalParams) => {
<DialogTrigger asChild>{params.dialogTrigger}</DialogTrigger>
)}
<DialogContent className={cn(
"overflow-auto",
"overflow-auto mx-auto",
"p-4 md:p-6",
getMinHeight(),
getMinWidth(),
params.customHeight,

View file

@ -65,6 +65,7 @@ function ActivityIndicators(props: Props) {
key={activity.activity_uuid}
>
<Link
prefetch={true}
href={
getUriWithOrg(orgslug, '') +
`/course/${courseid}/activity/${activity.activity_uuid.replace(

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

@ -11,10 +11,10 @@ function getMediaUrl() {
export function getCourseThumbnailMediaDirectory(
orgUUID: string,
courseId: string,
courseUUID: string,
fileId: string
) {
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/courses/${courseId}/thumbnails/${fileId}`
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/courses/${courseUUID}/thumbnails/${fileId}`
return uri
}
@ -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
};

View file

@ -2,6 +2,7 @@ import { getAPIUrl } from '@services/config/config'
import {
RequestBodyWithAuthHeader,
errorHandling,
getResponseMetadata,
} from '@services/utils/ts/requests'
/*
@ -18,6 +19,6 @@ export async function updatePassword(
`${getAPIUrl()}users/change_password/` + user_id,
RequestBodyWithAuthHeader('PUT', data, null, access_token)
)
const res = await errorHandling(result)
const res = await getResponseMetadata(result)
return res
}