mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: Init explore metadata features & redesign org settings panel
This commit is contained in:
parent
87787724c4
commit
bfd27ef6e3
12 changed files with 1419 additions and 217 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
29
apps/web/components/ui/switch.tsx
Normal file
29
apps/web/components/ui/switch.tsx
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue