mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: refactor the entire learnhouse project
This commit is contained in:
parent
f556e41dda
commit
4c215e91d5
247 changed files with 7716 additions and 1013 deletions
19
apps/web/app/api/revalidate/route.ts
Normal file
19
apps/web/app/api/revalidate/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const tag: any = request.nextUrl.searchParams.get("tag");
|
||||
revalidateTag(tag);
|
||||
|
||||
return NextResponse.json(
|
||||
{ revalidated: true, now: Date.now(), tag },
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
|
||||
export default function Loading() {
|
||||
// Or a custom loading skeleton component
|
||||
return (
|
||||
<PageLoading></PageLoading>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { default as React, } from "react";
|
||||
import AuthProvider from "@components/Security/AuthProvider";
|
||||
import EditorWrapper from "@components/Objects/Editor/EditorWrapper";
|
||||
import { getCourseMetadataWithAuthHeader } from "@services/courses/courses";
|
||||
import { cookies } from "next/headers";
|
||||
import { Metadata } from "next";
|
||||
import { getActivityWithAuthHeader } from "@services/courses/activities";
|
||||
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string, activityid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
// Get Org context information
|
||||
const course_meta = await getCourseMetadataWithAuthHeader(params.courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
|
||||
|
||||
return {
|
||||
title: `Edit - ${course_meta.course.name} Activity`,
|
||||
description: course_meta.course.mini_description,
|
||||
};
|
||||
}
|
||||
|
||||
const EditActivity = async (params: any) => {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
const activityid = params.params.activityid;
|
||||
const courseid = params.params.courseid;
|
||||
const orgslug = params.params.orgslug;
|
||||
|
||||
const courseInfo = await getCourseMetadataWithAuthHeader(courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
|
||||
const activity = await getActivityWithAuthHeader(activityid, { revalidate: 0, tags: ['activities'] }, access_token ? access_token : null)
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AuthProvider>
|
||||
<EditorWrapper orgslug={orgslug} course={courseInfo} activity={activity} content={activity.content}></EditorWrapper>
|
||||
</AuthProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditActivity;
|
||||
1
apps/web/app/editor/main.ts
Normal file
1
apps/web/app/editor/main.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const EDITOR = "main";
|
||||
80
apps/web/app/install/install.tsx
Normal file
80
apps/web/app/install/install.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
'use client'
|
||||
import React, { use, useEffect } from 'react'
|
||||
import { INSTALL_STEPS } from './steps/steps'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
|
||||
|
||||
|
||||
function InstallClient() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const step: any = parseInt(searchParams.get('step') || '0');
|
||||
const [stepNumber, setStepNumber] = React.useState(step)
|
||||
const [stepsState, setStepsState] = React.useState(INSTALL_STEPS)
|
||||
|
||||
function handleStepChange(stepNumber: number) {
|
||||
setStepNumber(stepNumber)
|
||||
router.push(`/install?step=${stepNumber}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setStepNumber(step)
|
||||
}, [step])
|
||||
|
||||
return (
|
||||
<GeneralWrapperStyled>
|
||||
<div className='flex justify-center '>
|
||||
<div className='grow'>
|
||||
<LearnHouseLogo />
|
||||
</div>
|
||||
<div className="steps flex space-x-2 justify-center text-sm p-3 bg-slate-50 rounded-full w-fit m-auto px-10">
|
||||
<div className="flex space-x-8">
|
||||
{stepsState.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center cursor-pointer space-x-2`}
|
||||
onClick={() => handleStepChange(index)}
|
||||
>
|
||||
<div className={`flex w-7 h-7 rounded-full text-slate-700 bg-slate-200 justify-center items-center m-auto align-middle hover:bg-slate-300 transition-all ${index === stepNumber ? 'bg-slate-300' : ''}`}>
|
||||
{index}
|
||||
</div>
|
||||
<div>{step.name}</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex pt-8 flex-col" >
|
||||
<h1 className='font-bold text-3xl'>{stepsState[stepNumber].name}</h1>
|
||||
<div className="pt-8">
|
||||
{stepsState[stepNumber].component}
|
||||
</div>
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
)
|
||||
}
|
||||
|
||||
const LearnHouseLogo = () => {
|
||||
return (
|
||||
<svg width="133" height="80" viewBox="0 0 433 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="80" height="80" rx="24" fill="black" />
|
||||
<rect width="80" height="80" rx="24" fill="url(#paint0_angular_1555_220)" />
|
||||
<rect x="0.5" y="0.5" width="79" height="79" rx="23.5" stroke="white" strokeOpacity="0.12" />
|
||||
<path d="M37.546 55.926V35.04L33.534 30.497L37.546 29.258V27.016L33.534 22.473L44.626 19.11V55.926L48.992 61H33.18L37.546 55.926Z" fill="white" />
|
||||
<path d="M113.98 54.98V30.2L109.22 24.81L113.98 23.34V20.68L109.22 15.29L122.38 11.3V54.98L127.56 61H108.8L113.98 54.98ZM157.704 41.19V41.26H135.234C136.004 50.29 140.834 54.07 146.294 54.07C151.054 54.07 155.254 51.69 156.304 48.75L157.354 49.17C154.834 55.54 149.864 61.98 141.534 61.98C132.364 61.98 127.184 53.79 127.184 45.39C127.184 36.36 132.784 26 144.194 26C152.524 26 157.634 31.6 157.704 41.05L157.774 41.19H157.704ZM148.674 39.16V38.53C148.674 31.04 145.664 28.1 142.584 28.1C137.264 28.1 135.094 34.47 135.094 38.67V39.16H148.674ZM178.717 61V55.12C176.057 57.71 171.157 61.7 166.537 61.7C161.707 61.7 158.137 59.32 158.137 53.65C158.137 46.51 166.607 42.87 178.717 38.6C178.717 33 178.577 28.66 172.837 28.66C167.237 28.66 163.877 32.58 160.307 37.9H159.817V26.7H188.657L187.117 32.72V56.45H187.187L192.367 61H178.717ZM178.717 53.23V40.56C167.727 44.97 167.377 47.98 167.377 51.34C167.377 54.7 169.687 56.17 172.627 56.17C174.797 56.17 176.967 55.05 178.717 53.23ZM221.429 39.09H220.869C217.789 31.74 213.659 29.29 210.439 29.29C205.609 29.29 205.609 32.79 205.609 39.93V54.98L212.119 61H192.029L197.209 54.98V32.09L192.449 26.7H221.429V39.09ZM261.467 61H242.707L247.747 54.98V39.44C247.747 34.05 246.977 30.62 241.587 30.62C238.997 30.62 236.337 31.74 234.097 34.75V54.98L239.137 61H220.377L225.697 54.98V36.08L220.937 30.69L234.097 26V32.37C236.897 28.03 241.447 25.86 245.647 25.86C252.787 25.86 256.147 30.48 256.147 37.06V54.98L261.467 61ZM274.343 11.3V32.23C277.143 27.89 281.693 25.72 285.893 25.72C293.033 25.72 296.393 30.34 296.393 36.92V54.98H296.463L301.643 61H282.883L287.993 55.05V39.3C287.993 33.91 287.223 30.48 281.833 30.48C279.243 30.48 276.583 31.6 274.343 34.61V54.98L279.523 61H260.763L265.943 54.98V21.38L261.183 15.99L274.343 11.3ZM335.945 42.31C335.945 51.34 329.855 61.84 316.835 61.84C306.895 61.84 301.645 53.79 301.645 45.39C301.645 36.36 307.735 25.86 320.755 25.86C330.695 25.86 335.945 33.91 335.945 42.31ZM316.975 28.52C311.165 28.52 310.535 34.82 310.535 39.02C310.535 49.94 314.525 59.18 320.685 59.18C325.865 59.18 327.195 52.32 327.195 48.68C327.195 37.76 323.135 28.52 316.975 28.52ZM349.01 26.63V48.12C349.01 53.51 349.78 56.94 355.17 56.94C357.55 56.94 360 55.75 361.82 53.65V32.72L356.64 26.63H370.22V55.26L374.98 61L361.82 61.42V55.82C359.3 59.32 356.08 61.7 351.11 61.7C343.97 61.7 340.61 57.08 340.61 50.5V32.72L335.36 26.63H349.01ZM374.617 47.77H375.177C376.997 53.79 382.527 59.04 388.267 59.04C391.137 59.04 393.517 57.64 393.517 54.49C393.517 46.23 374.967 50.29 374.967 36.43C374.967 31.25 379.517 26.7 386.657 26.7H394.357L396.947 25.23V36.85L396.527 36.78C394.007 32.23 389.807 28.87 385.327 28.94C382.387 29.01 380.707 30.83 380.707 33.56C380.707 40.77 399.887 37.62 399.887 50.43C399.887 58.55 391.697 61.7 386.167 61.7C382.667 61.7 377.907 61.21 375.247 60.09L374.617 47.77ZM430.416 41.19V41.26H407.946C408.716 50.29 413.546 54.07 419.006 54.07C423.766 54.07 427.966 51.69 429.016 48.75L430.066 49.17C427.546 55.54 422.576 61.98 414.246 61.98C405.076 61.98 399.896 53.79 399.896 45.39C399.896 36.36 405.496 26 416.906 26C425.236 26 430.346 31.6 430.416 41.05L430.486 41.19H430.416ZM421.386 39.16V38.53C421.386 31.04 418.376 28.1 415.296 28.1C409.976 28.1 407.806 34.47 407.806 38.67V39.16H421.386Z" fill="#121212" />
|
||||
<defs>
|
||||
<radialGradient id="paint0_angular_1555_220" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 40) rotate(90) scale(40)">
|
||||
<stop stopColor="#FBFBFB" stopOpacity="0.15" />
|
||||
<stop offset="0.442708" stopOpacity="0.1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default InstallClient
|
||||
18
apps/web/app/install/page.tsx
Normal file
18
apps/web/app/install/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react'
|
||||
import InstallClient from './install'
|
||||
|
||||
|
||||
export const metadata = {
|
||||
title: "Install LearnHouse",
|
||||
description: "Install Learnhouse on your server",
|
||||
}
|
||||
|
||||
function InstallPage() {
|
||||
return (
|
||||
<div className='bg-white h-screen'>
|
||||
<InstallClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallPage
|
||||
133
apps/web/app/install/steps/account_creation.tsx
Normal file
133
apps/web/app/install/steps/account_creation.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input } from '@components/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { getAPIUrl } from '@services/config/config';
|
||||
import { createNewUserInstall, updateInstall } from '@services/install/install';
|
||||
import { swrFetcher } from '@services/utils/ts/requests';
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import { BarLoader } from 'react-spinners';
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
|
||||
if (!values.email) {
|
||||
errors.email = 'Required';
|
||||
}
|
||||
else if (
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
|
||||
) {
|
||||
errors.email = 'Invalid email address';
|
||||
}
|
||||
|
||||
if (!values.password) {
|
||||
errors.password = 'Required';
|
||||
}
|
||||
else if (values.password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (!values.confirmPassword) {
|
||||
errors.confirmPassword = 'Required';
|
||||
}
|
||||
else if (values.confirmPassword !== values.password) {
|
||||
errors.confirmPassword = 'Passwords must match';
|
||||
}
|
||||
|
||||
if (!values.username) {
|
||||
errors.username = 'Required';
|
||||
}
|
||||
else if (values.username.length < 3) {
|
||||
errors.username = 'Username must be at least 3 characters';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
function AccountCreation() {
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher);
|
||||
const router = useRouter(
|
||||
|
||||
)
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
org_slug: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
username: '',
|
||||
},
|
||||
validate,
|
||||
onSubmit: values => {
|
||||
|
||||
let finalvalues = { ...values, org_slug: install.data[1].slug }
|
||||
let finalvalueswithoutpasswords = { ...values, password: '', confirmPassword: '', org_slug: install.data[1].slug }
|
||||
let install_data = { ...install.data, 3: finalvalues }
|
||||
let install_data_without_passwords = { ...install.data, 3: finalvalueswithoutpasswords }
|
||||
updateInstall({ ...install_data_without_passwords }, 4)
|
||||
createNewUserInstall(finalvalues)
|
||||
|
||||
// await 2 seconds
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false)
|
||||
}, 2000)
|
||||
|
||||
router.push('/install?step=4')
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<FormField name="email">
|
||||
<FormLabelAndMessage label='Email' message={formik.errors.email} />
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
{/* for password */}
|
||||
<FormField name="password">
|
||||
<FormLabelAndMessage label='Password' message={formik.errors.password} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
{/* for confirm password */}
|
||||
<FormField name="confirmPassword">
|
||||
|
||||
<FormLabelAndMessage label='Confirm Password' message={formik.errors.confirmPassword} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.confirmPassword} type="password" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
{/* for username */}
|
||||
<FormField name="username">
|
||||
|
||||
<FormLabelAndMessage label='Username' message={formik.errors.username} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-row-reverse py-4">
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{ borderRadius: 60, }} width={60} color="#ffffff" />
|
||||
: "Create Admin Account"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</div>
|
||||
|
||||
</FormLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountCreation
|
||||
45
apps/web/app/install/steps/default_elements.tsx
Normal file
45
apps/web/app/install/steps/default_elements.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { getAPIUrl } from '@services/config/config';
|
||||
import { createDefaultElements, updateInstall } from '@services/install/install';
|
||||
import { swrFetcher } from '@services/utils/ts/requests';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import useSWR from "swr";
|
||||
|
||||
function DefaultElements() {
|
||||
const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = React.useState(false);
|
||||
const router = useRouter()
|
||||
|
||||
function createDefElementsAndUpdateInstall() {
|
||||
try {
|
||||
createDefaultElements()
|
||||
// add an {} to the install.data object
|
||||
|
||||
let install_data = { ...install.data, 2: { status: 'OK' } }
|
||||
|
||||
updateInstall(install_data, 3)
|
||||
// await 2 seconds
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false)
|
||||
}, 2000)
|
||||
|
||||
router.push('/install?step=3')
|
||||
setIsSubmitted(true)
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex py-10 justify-center items-center space-x-3'>
|
||||
<h1>Install Default Elements </h1>
|
||||
<div onClick={createDefElementsAndUpdateInstall} className='p-3 font-bold bg-gray-200 text-gray-900 rounded-lg hover:cursor-pointer' >
|
||||
Install
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DefaultElements
|
||||
19
apps/web/app/install/steps/disable_install_mode.tsx
Normal file
19
apps/web/app/install/steps/disable_install_mode.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Check, Link } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
function DisableInstallMode() {
|
||||
return (
|
||||
<div className='p-4 bg-green-300 text-green-950 rounded-md flex space-x-4 items-center'>
|
||||
<div>
|
||||
<Check size={32} />
|
||||
</div>
|
||||
<div><p className='font-bold text-lg'>You have reached the end of the Installation process, <b><i>please don't forget to disable installation mode.</i></b> </p>
|
||||
<div className='flex space-x-2 items-center'>
|
||||
<Link size={20} />
|
||||
<a rel='noreferrer' target='_blank' className="text-blue-950 font-medium" href="http://docs.learnhouse.app">LearnHouse Docs</a>
|
||||
</div></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisableInstallMode
|
||||
39
apps/web/app/install/steps/finish.tsx
Normal file
39
apps/web/app/install/steps/finish.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { getAPIUrl } from '@services/config/config';
|
||||
import { updateInstall } from '@services/install/install';
|
||||
import { swrFetcher } from '@services/utils/ts/requests';
|
||||
import { Check } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import useSWR from "swr";
|
||||
|
||||
const Finish = () => {
|
||||
const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher);
|
||||
const router = useRouter()
|
||||
|
||||
async function finishInstall() {
|
||||
|
||||
let install_data = { ...install.data, 5: { status: 'OK' } }
|
||||
|
||||
let data = await updateInstall(install_data, 6)
|
||||
if (data) {
|
||||
router.push('/install?step=6')
|
||||
}
|
||||
else {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex py-10 justify-center items-center space-x-3'>
|
||||
<h1>Installation Complete</h1>
|
||||
<br />
|
||||
<Check size={32} />
|
||||
<div onClick={finishInstall} className='p-3 font-bold bg-gray-200 text-gray-900 rounded-lg hover:cursor-pointer' >
|
||||
Next Step
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Finish
|
||||
69
apps/web/app/install/steps/get_started.tsx
Normal file
69
apps/web/app/install/steps/get_started.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import PageLoading from '@components/Objects/Loaders/PageLoading';
|
||||
import { getAPIUrl } from '@services/config/config';
|
||||
import { swrFetcher } from '@services/utils/ts/requests';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { use, useEffect } from 'react'
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
function GetStarted() {
|
||||
const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher);
|
||||
const router = useRouter()
|
||||
|
||||
async function startInstallation() {
|
||||
let res = await fetch(`${getAPIUrl()}install/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
|
||||
if (res.status == 200) {
|
||||
mutate(`${getAPIUrl()}install/latest`)
|
||||
router.refresh();
|
||||
router.push(`/install?step=1`)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
function redirectToStep() {
|
||||
const step = install.step
|
||||
router.push(`/install?step=${step}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (install) {
|
||||
redirectToStep()
|
||||
}
|
||||
}, [install])
|
||||
|
||||
|
||||
if (error) return <div className='flex py-10 justify-center items-center space-x-3'>
|
||||
<h1>Start a new installation</h1>
|
||||
<div onClick={startInstallation} className='p-3 font-bold bg-green-200 text-green-900 rounded-lg hover:cursor-pointer' >
|
||||
Start
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if (isLoading) return <PageLoading />
|
||||
if (install) {
|
||||
return (
|
||||
<div>
|
||||
<div className='flex py-10 justify-center items-center space-x-3'>
|
||||
<h1>You already started an installation</h1>
|
||||
<div onClick={redirectToStep} className='p-3 font-bold bg-orange-200 text-orange-900 rounded-lg hover:cursor-pointer' >
|
||||
Continue
|
||||
</div>
|
||||
<div onClick={startInstallation} className='p-3 font-bold bg-green-200 text-green-900 rounded-lg hover:cursor-pointer' >
|
||||
Start
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default GetStarted
|
||||
138
apps/web/app/install/steps/org_creation.tsx
Normal file
138
apps/web/app/install/steps/org_creation.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
|
||||
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input } from '@components/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { useFormik } from 'formik';
|
||||
import { BarLoader } from 'react-spinners';
|
||||
import React from 'react'
|
||||
import { createNewOrganization } from '@services/organizations/orgs';
|
||||
import { swrFetcher } from '@services/utils/ts/requests';
|
||||
import { getAPIUrl } from '@services/config/config';
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { createNewOrgInstall, updateInstall } from '@services/install/install';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
|
||||
if (!values.name) {
|
||||
errors.name = 'Required';
|
||||
}
|
||||
|
||||
if (!values.description) {
|
||||
errors.description = 'Required';
|
||||
}
|
||||
|
||||
if (!values.slug) {
|
||||
errors.slug = 'Required';
|
||||
}
|
||||
|
||||
if (!values.email) {
|
||||
errors.email = 'Required';
|
||||
}
|
||||
else if (
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
|
||||
) {
|
||||
errors.email = 'Invalid email address';
|
||||
}
|
||||
|
||||
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
function OrgCreation() {
|
||||
const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = React.useState(false);
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
function createOrgAndUpdateInstall(values: any) {
|
||||
try {
|
||||
createNewOrgInstall(values)
|
||||
install.data = {
|
||||
1: values
|
||||
}
|
||||
let install_data = { ...install.data, 1: values }
|
||||
updateInstall(install_data, 2)
|
||||
// await 2 seconds
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false)
|
||||
}, 2000)
|
||||
|
||||
router.push('/install?step=2')
|
||||
setIsSubmitted(true)
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
slug: '',
|
||||
email: '',
|
||||
},
|
||||
validate,
|
||||
onSubmit: values => {
|
||||
createOrgAndUpdateInstall(values)
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<FormField name="name">
|
||||
<FormLabelAndMessage label='Name' message={formik.errors.name} />
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.name} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="description">
|
||||
<FormLabelAndMessage label='Description' message={formik.errors.description} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.description} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="slug">
|
||||
|
||||
<FormLabelAndMessage label='Slug' message={formik.errors.slug} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.slug} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
{/* for username */}
|
||||
<FormField name="email">
|
||||
|
||||
<FormLabelAndMessage label='Email' message={formik.errors.email} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-row-reverse py-4">
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{ borderRadius: 60, }} width={60} color="#ffffff" />
|
||||
: "Create Organization"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</div>
|
||||
|
||||
{isSubmitted && <div className='flex space-x-3'> <Check /> Organization Created Successfully</div>}
|
||||
|
||||
|
||||
</FormLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrgCreation
|
||||
43
apps/web/app/install/steps/sample_data.tsx
Normal file
43
apps/web/app/install/steps/sample_data.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { getAPIUrl } from '@services/config/config';
|
||||
import { createSampleDataInstall, updateInstall } from '@services/install/install';
|
||||
import { swrFetcher } from '@services/utils/ts/requests';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
function SampleData() {
|
||||
const { data: install, error: error, isLoading } = useSWR(`${getAPIUrl()}install/latest`, swrFetcher);
|
||||
const router = useRouter()
|
||||
|
||||
function createSampleData() {
|
||||
|
||||
try {
|
||||
let username = install.data[3].username
|
||||
let slug = install.data[1].slug
|
||||
|
||||
createSampleDataInstall(username, slug)
|
||||
|
||||
let install_data = { ...install.data, 4: { status: 'OK' } }
|
||||
updateInstall(install_data, 5)
|
||||
|
||||
router.push('/install?step=5')
|
||||
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className='flex py-10 justify-center items-center space-x-3'>
|
||||
<h1>Install Sample data on your organization </h1>
|
||||
<div onClick={createSampleData} className='p-3 font-bold bg-purple-200 text-pruple-900 rounded-lg hover:cursor-pointer' >
|
||||
Start
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SampleData
|
||||
53
apps/web/app/install/steps/steps.tsx
Normal file
53
apps/web/app/install/steps/steps.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import AccountCreation from "./account_creation";
|
||||
import DefaultElements from "./default_elements";
|
||||
import DisableInstallMode from "./disable_install_mode";
|
||||
import Finish from "./finish";
|
||||
import GetStarted from "./get_started";
|
||||
import OrgCreation from "./org_creation";
|
||||
import SampleData from "./sample_data";
|
||||
|
||||
export const INSTALL_STEPS = [
|
||||
{
|
||||
id: "INSTALL_STATUS",
|
||||
name: "Get started",
|
||||
component: <GetStarted />,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "ORGANIZATION_CREATION",
|
||||
name: "Organization Creation",
|
||||
component: <OrgCreation />,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "DEFAULT_ELEMENTS",
|
||||
name: "Default Elements",
|
||||
component: <DefaultElements />,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "ACCOUNT_CREATION",
|
||||
name: "Account Creation",
|
||||
component: <AccountCreation />,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "SAMPLE_DATA",
|
||||
name: "Sample Data",
|
||||
component: <SampleData />,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "FINISH",
|
||||
name: "Finish",
|
||||
component: <Finish />,
|
||||
completed: false,
|
||||
|
||||
},
|
||||
{
|
||||
id: "DISABLING_INSTALLATION_MODE",
|
||||
name: "Disabling Installation Mode",
|
||||
component: <DisableInstallMode />,
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
31
apps/web/app/layout.tsx
Normal file
31
apps/web/app/layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
import "../styles/globals.css";
|
||||
import StyledComponentsRegistry from "../components/Utils/libs/styled-registry";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const variants = {
|
||||
hidden: { opacity: 0, x: 0, y: 0 },
|
||||
enter: { opacity: 1, x: 0, y: 0 },
|
||||
exit: { opacity: 0, x: 0, y: 0 },
|
||||
};
|
||||
return (
|
||||
<html className="" lang="en">
|
||||
<head />
|
||||
<body>
|
||||
<StyledComponentsRegistry>
|
||||
<motion.main
|
||||
variants={variants} // Pass the variant object into Framer Motion
|
||||
initial="hidden" // Set the initial state to variants.hidden
|
||||
animate="enter" // Animated state to variants.enter
|
||||
exit="exit" // Exit state (used later) to variants.exit
|
||||
transition={{ type: "linear" }} // Set the transition to linear
|
||||
className=""
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
</StyledComponentsRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
51
apps/web/app/organizations/new/page.tsx
Normal file
51
apps/web/app/organizations/new/page.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { createNewOrganization } from "../../../services/organizations/orgs";
|
||||
|
||||
const Organizations = () => {
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [slug, setSlug] = React.useState("");
|
||||
|
||||
const handleNameChange = (e: any) => {
|
||||
setName(e.target.value);
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (e: any) => {
|
||||
setDescription(e.target.value);
|
||||
};
|
||||
|
||||
const handleEmailChange = (e: any) => {
|
||||
setEmail(e.target.value);
|
||||
};
|
||||
|
||||
const handleSlugChange = (e: any) => {
|
||||
setSlug(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
let logo = ''
|
||||
const status = await createNewOrganization({ name, description, email, logo, slug, default: false });
|
||||
alert(JSON.stringify(status));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-bold text-lg">New Organization</div>
|
||||
Name: <input onChange={handleNameChange} type="text" />
|
||||
<br />
|
||||
Description: <input onChange={handleDescriptionChange} type="text" />
|
||||
<br />
|
||||
Slug: <input onChange={handleSlugChange} type="text" />
|
||||
<br />
|
||||
Email Address: <input onChange={handleEmailChange} type="text" />
|
||||
<br />
|
||||
<button onClick={handleSubmit}>Create</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Organizations;
|
||||
56
apps/web/app/organizations/page.tsx
Normal file
56
apps/web/app/organizations/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client"; //todo: use server components
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { deleteOrganizationFromBackend } from "@services/organizations/orgs";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { swrFetcher } from "@services/utils/ts/requests";
|
||||
import { getAPIUrl, getUriWithOrg } from "@services/config/config";
|
||||
import AuthProvider from "@components/Security/AuthProvider";
|
||||
|
||||
const Organizations = () => {
|
||||
const { data: organizations, error } = useSWR(`${getAPIUrl()}orgs/user/page/1/limit/10`, swrFetcher)
|
||||
|
||||
async function deleteOrganization(org_id: any) {
|
||||
const response = await deleteOrganizationFromBackend(org_id);
|
||||
response && mutate(`${getAPIUrl()}orgs/user/page/1/limit/10`, organizations.filter((org: any) => org.org_id !== org_id));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthProvider />
|
||||
<div className="font-bold text-lg">
|
||||
Your Organizations{" "}
|
||||
<Link href="/organizations/new">
|
||||
<button className="bg-blue-500 text-white px-2 py-1 rounded-md hover:bg-blue-600 focus:outline-none">
|
||||
+
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{error && <p className="text-red-500">Failed to load</p>}
|
||||
{!organizations ? (
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
) : (
|
||||
<div>
|
||||
{organizations.map((org: any) => (
|
||||
<div key={org.org_id} className="flex items-center justify-between mb-4">
|
||||
<Link href={getUriWithOrg(org.slug, "/")}>
|
||||
<h3 className="text-blue-500 cursor-pointer hover:underline">{org.name}</h3>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deleteOrganization(org.org_id)}
|
||||
className="px-3 py-1 text-white bg-red-500 rounded-md hover:bg-red-600 focus:outline-none"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Organizations;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
'use client'; // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ErrorUI></ErrorUI>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<PageLoading></PageLoading>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
|
||||
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth";
|
||||
import { getBackendUrl, getUriWithOrg } from "@services/config/config";
|
||||
import { getCollectionByIdWithAuthHeader } from "@services/courses/collections";
|
||||
import { getCourseThumbnailMediaDirectory } from "@services/media/media";
|
||||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import Link from "next/link";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string, collectionid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
const col = await getCollectionByIdWithAuthHeader(params.collectionid, access_token ? access_token : null, { revalidate: 0, tags: ['collections'] });
|
||||
|
||||
// SEO
|
||||
return {
|
||||
title: `Collection : ${col.name} — ${org.name}`,
|
||||
description: `${col.description} `,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
}
|
||||
},
|
||||
openGraph: {
|
||||
title: `Collection : ${col.name} — ${org.name}`,
|
||||
description: `${col.description} `,
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CollectionPage = async (params: any) => {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
const orgslug = params.params.orgslug;
|
||||
const col = await getCollectionByIdWithAuthHeader(params.params.collectionid, access_token ? access_token : null, { revalidate: 0, tags: ['collections'] });
|
||||
|
||||
const removeCoursePrefix = (courseid: string) => {
|
||||
return courseid.replace("course_", "")
|
||||
}
|
||||
|
||||
|
||||
return <GeneralWrapperStyled>
|
||||
<h2 className="text-sm font-bold text-gray-400">Collection</h2>
|
||||
<h1 className="text-3xl font-bold">{col.name}</h1>
|
||||
<br />
|
||||
<div className="home_courses flex flex-wrap">
|
||||
{col.courses.map((course: any) => (
|
||||
<div className="pr-8" key={course.course_id}>
|
||||
<Link href={getUriWithOrg(orgslug, "/course/" + removeCoursePrefix(course.course_id))}>
|
||||
<div className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-[249px] h-[131px] bg-cover" style={{ backgroundImage: `url(${getCourseThumbnailMediaDirectory(course.org_id, course.course_id, course.thumbnail)})` }}>
|
||||
</div>
|
||||
</Link>
|
||||
<h2 className="font-bold text-lg w-[250px] py-2">{course.name}</h2>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</GeneralWrapperStyled>;
|
||||
};
|
||||
|
||||
export default CollectionPage;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
|
||||
export default function Loading() {
|
||||
// Or a custom loading skeleton component
|
||||
return (
|
||||
<PageLoading></PageLoading>
|
||||
)
|
||||
|
||||
}
|
||||
118
apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx
Normal file
118
apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { createCollection } from "@services/courses/collections";
|
||||
import useSWR from "swr";
|
||||
import { getAPIUrl, getUriWithOrg } from "@services/config/config";
|
||||
import { revalidateTags, swrFetcher } from "@services/utils/ts/requests";
|
||||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
|
||||
function NewCollection(params: any) {
|
||||
const orgslug = params.params.orgslug;
|
||||
const [name, setName] = React.useState("");
|
||||
const [org, setOrg] = React.useState({}) as any;
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [selectedCourses, setSelectedCourses] = React.useState([]) as any;
|
||||
const router = useRouter();
|
||||
|
||||
const { data: courses, error: error } = useSWR(`${getAPIUrl()}courses/org_slug/${orgslug}/page/1/limit/10`, swrFetcher);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function getOrg() {
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800 });
|
||||
setOrg(org);
|
||||
}
|
||||
getOrg();
|
||||
}, []);
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value);
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDescription(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
const collection = {
|
||||
name: name,
|
||||
description: description,
|
||||
courses: selectedCourses,
|
||||
public: true,
|
||||
org_id: org.org_id,
|
||||
};
|
||||
await createCollection(collection);
|
||||
await revalidateTags(["collections"], orgslug);
|
||||
router.refresh();
|
||||
router.prefetch(getUriWithOrg(orgslug, "/collections"));
|
||||
router.push(getUriWithOrg(orgslug, "/collections"));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-64 m-auto py-20">
|
||||
<div className="font-bold text-lg mb-4">Add new</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"
|
||||
/>
|
||||
|
||||
{!courses ? (
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
) : (
|
||||
<div>
|
||||
{courses.map((course: any) => (
|
||||
<div key={course.course_id} className="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={course.course_id}
|
||||
name={course.course_id}
|
||||
value={course.course_id}
|
||||
checked={selectedCourses.includes(course.course_id)}
|
||||
onChange={(e) => {
|
||||
const courseId = e.target.value;
|
||||
setSelectedCourses((prevSelectedCourses: string[]) => {
|
||||
if (e.target.checked) {
|
||||
return [...prevSelectedCourses, courseId];
|
||||
} else {
|
||||
return prevSelectedCourses.filter((selectedCourse) => selectedCourse !== courseId);
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="mr-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor={course.course_id} className="text-sm">{course.name}</label>
|
||||
</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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewCollection;
|
||||
97
apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
Normal file
97
apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import AuthenticatedClientElement from "@components/Security/AuthenticatedClientElement";
|
||||
import TypeOfContentTitle from "@components/StyledElements/Titles/TypeOfContentTitle";
|
||||
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
|
||||
import { getUriWithOrg } from "@services/config/config";
|
||||
import { getOrgCollectionsWithAuthHeader } from "@services/courses/collections";
|
||||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { getAccessTokenFromRefreshTokenCookie } from "@services/auth/auth";
|
||||
import CollectionThumbnail from "@components/Objects/Other/CollectionThumbnail";
|
||||
import NewCollectionButton from "@components/StyledElements/Buttons/NewCollectionButton";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
// SEO
|
||||
return {
|
||||
title: `Collections — ${org.name}`,
|
||||
description: `Collections of courses from ${org.name}`,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
}
|
||||
},
|
||||
openGraph: {
|
||||
title: `Collections — ${org.name}`,
|
||||
description: `Collections of courses from ${org.name}`,
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CollectionsPage = async (params: any) => {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
const orgslug = params.params.orgslug;
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
const org_id = org.org_id;
|
||||
const collections = await getOrgCollectionsWithAuthHeader(org_id, access_token ? access_token : null, { revalidate: 0, tags: ['collections'] });
|
||||
|
||||
return (
|
||||
<GeneralWrapperStyled>
|
||||
<div className="flex justify-between" >
|
||||
<TypeOfContentTitle title="Collections" type="col" />
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={org_id}>
|
||||
<Link className="flex justify-center" href={getUriWithOrg(orgslug, "/collections/new")}>
|
||||
<NewCollectionButton />
|
||||
</Link>
|
||||
</AuthenticatedClientElement>
|
||||
</div>
|
||||
<div className="home_collections flex flex-wrap">
|
||||
{collections.map((collection: any) => (
|
||||
<div className="flex flex-col py-1 px-3" key={collection.collection_id}>
|
||||
<CollectionThumbnail collection={collection} orgslug={orgslug} org_id={org_id} />
|
||||
</div>
|
||||
))}
|
||||
{collections.length == 0 &&
|
||||
<div className="flex mx-auto h-[400px]">
|
||||
<div className="flex flex-col justify-center text-center items-center space-y-5">
|
||||
<div className='mx-auto'>
|
||||
<svg width="120" height="120" viewBox="0 0 295 295" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect opacity="0.51" x="10" y="10" width="275" height="275" rx="75" stroke="#4B5564" strokeOpacity="0.15" strokeWidth="20" />
|
||||
<path d="M135.8 200.8V130L122.2 114.6L135.8 110.4V102.8L122.2 87.4L159.8 76V200.8L174.6 218H121L135.8 200.8Z" fill="#4B5564" fillOpacity="0.08" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
<h1 className="text-3xl font-bold text-gray-600">No collections yet</h1>
|
||||
<p className="text-lg text-gray-400">Create a collection to group courses together</p>
|
||||
</div>
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={org_id}>
|
||||
<Link href={getUriWithOrg(orgslug, "/collections/new")}>
|
||||
<NewCollectionButton />
|
||||
</Link>
|
||||
</AuthenticatedClientElement>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionsPage
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import { getUriWithOrg } from "@services/config/config";
|
||||
import Canva from "@components/Objects/Activities/DynamicCanva/DynamicCanva";
|
||||
import VideoActivity from "@components/Objects/Activities/Video/Video";
|
||||
import { Check } from "lucide-react";
|
||||
import { markActivityAsComplete } from "@services/courses/activity";
|
||||
import DocumentPdfActivity from "@components/Objects/Activities/DocumentPdf/DocumentPdf";
|
||||
import ActivityIndicators from "@components/Pages/Courses/ActivityIndicators";
|
||||
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthenticatedClientElement from "@components/Security/AuthenticatedClientElement";
|
||||
import { getCourseThumbnailMediaDirectory } from "@services/media/media";
|
||||
|
||||
interface ActivityClientProps {
|
||||
activityid: string;
|
||||
courseid: string;
|
||||
orgslug: string;
|
||||
activity: any;
|
||||
course: any;
|
||||
}
|
||||
|
||||
|
||||
function ActivityClient(props: ActivityClientProps) {
|
||||
const activityid = props.activityid;
|
||||
const courseid = props.courseid;
|
||||
const orgslug = props.orgslug;
|
||||
const activity = props.activity;
|
||||
const course = props.course;
|
||||
|
||||
function getChapterName(chapterId: string) {
|
||||
let chapterName = "";
|
||||
course.chapters.forEach((chapter: any) => {
|
||||
if (chapter.id === chapterId) {
|
||||
chapterName = chapter.name;
|
||||
}
|
||||
});
|
||||
return chapterName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<GeneralWrapperStyled>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="flex space-x-6">
|
||||
<div className="flex">
|
||||
<Link href={getUriWithOrg(orgslug, "") + `/course/${courseid}`}>
|
||||
<img className="w-[100px] h-[57px] rounded-md drop-shadow-md" src={`${getCourseThumbnailMediaDirectory(course.course.org_id, course.course.course_id, course.course.thumbnail)}`} alt="" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<p className="font-bold text-gray-700 text-md">Course </p>
|
||||
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase" >{course.course.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<ActivityIndicators course_id={courseid} current_activity={activityid} orgslug={orgslug} course={course} />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<p className="font-bold text-gray-700 text-md">Chapter : {getChapterName(activity.coursechapter_id)}</p>
|
||||
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase" >{activity.name}</h1>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<AuthenticatedClientElement checkMethod="authentication">
|
||||
<MarkStatus activityid={activityid} course={course} orgslug={orgslug} courseid={courseid} />
|
||||
|
||||
</AuthenticatedClientElement>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activity ? (
|
||||
<div className={`p-7 pt-4 drop-shadow-sm rounded-lg ${activity.type == 'dynamic' ? 'bg-white' : 'bg-zinc-950'}`}>
|
||||
<div>
|
||||
{activity.type == "dynamic" && <Canva content={activity.content} activity={activity} />}
|
||||
{/* todo : use apis & streams instead of this */}
|
||||
{activity.type == "video" && <VideoActivity course={course} activity={activity} />}
|
||||
{activity.type == "documentpdf" && <DocumentPdfActivity course={course} activity={activity} />}
|
||||
</div>
|
||||
</div>
|
||||
) : (<div></div>)}
|
||||
{<div style={{ height: "100px" }}></div>}
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function MarkStatus(props: { activityid: string, course: any, orgslug: string, courseid: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
async function markActivityAsCompleteFront() {
|
||||
const trail = await markActivityAsComplete(props.orgslug, props.courseid, props.activityid);
|
||||
router.refresh();
|
||||
|
||||
// refresh page (FIX for Next.js BUG)
|
||||
//window.location.reload();
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<>{props.course.trail.activities_marked_complete &&
|
||||
props.course.trail.activities_marked_complete.includes("activity_" + props.activityid) &&
|
||||
props.course.trail.status == "ongoing" ? (
|
||||
<div className="bg-teal-600 rounded-md drop-shadow-md flex flex-col p-3 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out" >
|
||||
<i>
|
||||
<Check size={15}></Check>
|
||||
</i>{" "}
|
||||
Already completed
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-zinc-600 rounded-md drop-shadow-md flex flex-col p-3 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out" onClick={markActivityAsCompleteFront}>
|
||||
{" "}
|
||||
<i>
|
||||
<Check size={15}></Check>
|
||||
</i>{" "}
|
||||
Mark as complete
|
||||
</div>
|
||||
)}</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default ActivityClient;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
'use client'; // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ErrorUI></ErrorUI>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
|
||||
export default function Loading() {
|
||||
// Or a custom loading skeleton component
|
||||
return (
|
||||
<PageLoading></PageLoading>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { getActivityWithAuthHeader } from "@services/courses/activities";
|
||||
import { getCourseMetadataWithAuthHeader } from "@services/courses/courses";
|
||||
import { cookies } from "next/headers";
|
||||
import ActivityClient from "./activity";
|
||||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import { Metadata } from "next";
|
||||
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth";
|
||||
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string, activityid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
const course_meta = await getCourseMetadataWithAuthHeader(params.courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
|
||||
const activity = await getActivityWithAuthHeader(params.activityid, { revalidate: 0, tags: ['activities'] }, access_token ? access_token : null)
|
||||
|
||||
// SEO
|
||||
return {
|
||||
title: activity.name + ` — ${course_meta.course.name} Course`,
|
||||
description: course_meta.course.mini_description,
|
||||
keywords: course_meta.course.learnings,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
}
|
||||
},
|
||||
openGraph: {
|
||||
title: activity.name + ` — ${course_meta.course.name} Course`,
|
||||
description: course_meta.course.mini_description,
|
||||
type: activity.type === 'video' ? 'video.other' : 'article',
|
||||
publishedTime: course_meta.course.creationDate,
|
||||
tags: course_meta.course.learnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ActivityPage = async (params: any) => {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
const activityid = params.params.activityid;
|
||||
const courseid = params.params.courseid;
|
||||
const orgslug = params.params.orgslug;
|
||||
|
||||
const course_meta = await getCourseMetadataWithAuthHeader(courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
|
||||
const activity = await getActivityWithAuthHeader(activityid, { revalidate: 0, tags: ['activities'] }, access_token ? access_token : null)
|
||||
return (
|
||||
<>
|
||||
<ActivityClient
|
||||
activityid={activityid}
|
||||
courseid={courseid}
|
||||
orgslug={orgslug}
|
||||
activity={activity}
|
||||
course={course_meta}
|
||||
/></>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActivityPage
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
"use client";
|
||||
import { removeCourse, startCourse } from "@services/courses/activity";
|
||||
import Link from "next/link";
|
||||
import React, { use, useEffect, useState } from "react";
|
||||
import { getUriWithOrg } from "@services/config/config";
|
||||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
import { revalidateTags } from "@services/utils/ts/requests";
|
||||
import ActivityIndicators from "@components/Pages/Courses/ActivityIndicators";
|
||||
import { useRouter } from "next/navigation";
|
||||
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
|
||||
import { getCourseThumbnailMediaDirectory } from "@services/media/media";
|
||||
import { ArrowRight, Check, File, Sparkles, Star, Video } from "lucide-react";
|
||||
import Avvvatars from "avvvatars-react";
|
||||
import { getUser } from "@services/users/users";
|
||||
|
||||
const CourseClient = (props: any) => {
|
||||
const [user, setUser] = useState<any>({});
|
||||
const courseid = props.courseid;
|
||||
const orgslug = props.orgslug;
|
||||
const course = props.course;
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
|
||||
async function getUserUI() {
|
||||
let user_id = course.course.authors[0];
|
||||
const user = await getUser(user_id);
|
||||
setUser(user);
|
||||
console.log(user);
|
||||
}
|
||||
|
||||
async function startCourseUI() {
|
||||
// Create activity
|
||||
await startCourse("course_" + courseid, orgslug);
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
|
||||
// refresh page (FIX for Next.js BUG)
|
||||
// window.location.reload();
|
||||
}
|
||||
|
||||
async function quitCourse() {
|
||||
// Close activity
|
||||
let activity = await removeCourse("course_" + courseid, orgslug);
|
||||
// Mutate course
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getUserUI();
|
||||
}
|
||||
, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!course ? (
|
||||
<PageLoading></PageLoading>
|
||||
) : (
|
||||
<GeneralWrapperStyled>
|
||||
<div className="pb-3">
|
||||
<p className="text-md font-bold text-gray-400 pb-2">Course</p>
|
||||
<h1 className="text-3xl -mt-3 font-bold">
|
||||
{course.course.name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-auto h-[300px] bg-cover bg-center mb-4" style={{ backgroundImage: `url(${getCourseThumbnailMediaDirectory(course.course.org_id, course.course.course_id, course.course.thumbnail)})` }}>
|
||||
</div>
|
||||
|
||||
<ActivityIndicators course_id={props.course.course.course_id} orgslug={orgslug} course={course} />
|
||||
|
||||
<div className="flex flex-row pt-10">
|
||||
<div className="course_metadata_left grow space-y-2">
|
||||
<h2 className="py-3 text-2xl font-bold">Description</h2>
|
||||
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
|
||||
<p className="py-5 px-5">{course.course.description}</p>
|
||||
</div>
|
||||
|
||||
<h2 className="py-3 text-2xl font-bold">What you will learn</h2>
|
||||
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden px-5 py-5 space-y-2">
|
||||
{course.course.learnings.map((learning: any) => {
|
||||
return (
|
||||
<div key={learning}
|
||||
className="flex space-x-2 items-center font-semibold text-gray-500 capitalize">
|
||||
<div className="px-2 py-2 rounded-full">
|
||||
<Check className="text-gray-400" size={15} />
|
||||
</div>
|
||||
<p>{learning}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="py-3 text-2xl font-bold">Course Lessons</h2>
|
||||
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
|
||||
{course.chapters.map((chapter: any) => {
|
||||
return (
|
||||
<div
|
||||
key={chapter}
|
||||
className=""
|
||||
>
|
||||
<div className="flex text-lg py-4 px-4 outline outline-1 outline-neutral-200/40 font-bold bg-neutral-50 text-neutral-600 items-center">
|
||||
<h3 className="grow">{chapter.name}</h3>
|
||||
<p className="text-sm font-normal text-neutral-400 px-3 py-[2px] outline-1 outline outline-neutral-200 rounded-full ">
|
||||
{chapter.activities.length} Activities
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="py-3"
|
||||
>{chapter.activities.map((activity: any) => {
|
||||
return (
|
||||
<>
|
||||
<p className="flex text-md">
|
||||
|
||||
</p>
|
||||
<div className="flex space-x-1 py-2 px-4 items-center">
|
||||
<div className="courseicon items-center flex space-x-2 text-neutral-400">
|
||||
{activity.type === "dynamic" &&
|
||||
<div className="bg-gray-100 px-2 py-2 rounded-full">
|
||||
<Sparkles className="text-gray-400" size={13} />
|
||||
</div>
|
||||
}
|
||||
{activity.type === "video" &&
|
||||
<div className="bg-gray-100 px-2 py-2 rounded-full">
|
||||
<Video className="text-gray-400" size={13} />
|
||||
</div>
|
||||
}
|
||||
{activity.type === "documentpdf" &&
|
||||
<div className="bg-gray-100 px-2 py-2 rounded-full">
|
||||
<File className="text-gray-400" size={13} />
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
<Link className="flex font-semibold grow pl-2 text-neutral-500" href={getUriWithOrg(orgslug, "") + `/course/${courseid}/activity/${activity.id.replace("activity_", "")}`} rel="noopener noreferrer">
|
||||
<p>{activity.name}</p>
|
||||
</Link>
|
||||
<div className="flex ">
|
||||
{activity.type === "dynamic" &&
|
||||
<>
|
||||
<Link className="flex grow pl-2 text-gray-500" href={getUriWithOrg(orgslug, "") + `/course/${courseid}/activity/${activity.id.replace("activity_", "")}`} rel="noopener noreferrer">
|
||||
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
|
||||
<p>Page</p>
|
||||
<ArrowRight size={13} /></div>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
{activity.type === "video" &&
|
||||
<>
|
||||
<Link className="flex grow pl-2 text-gray-500" href={getUriWithOrg(orgslug, "") + `/course/${courseid}/activity/${activity.id.replace("activity_", "")}`} rel="noopener noreferrer">
|
||||
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
|
||||
<p>Video</p>
|
||||
<ArrowRight size={13} /></div>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
{activity.type === "documentpdf" &&
|
||||
<>
|
||||
<Link className="flex grow pl-2 text-gray-500" href={getUriWithOrg(orgslug, "") + `/course/${courseid}/activity/${activity.id.replace("activity_", "")}`} rel="noopener noreferrer">
|
||||
<div className="text-xs bg-gray-100 text-gray-400 font-bold px-2 py-1 rounded-full flex space-x-1 items-center">
|
||||
<p>Document</p>
|
||||
<ArrowRight size={13} /></div>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="course_metadata_right space-y-3 w-64 antialiased flex flex-col ml-10 h-fit p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
|
||||
{ user &&
|
||||
<div className="flex mx-auto space-x-3 px-2 py-2 items-center">
|
||||
<div className="">
|
||||
<Avvvatars border borderSize={5} borderColor="white" size={50} shadow value={course.course.authors[0]} style='shape' />
|
||||
</div>
|
||||
<div className="-space-y-2 ">
|
||||
<div className="text-[12px] text-neutral-400 font-semibold">Author</div>
|
||||
<div className="text-xl font-bold text-neutral-800">{user.full_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{course.trail.status == "ongoing" ? (
|
||||
<button className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-[200px] drop-shadow-md bg-red-600 hover:bg-red-700 hover:cursor-pointer" onClick={quitCourse}>
|
||||
Quit Course
|
||||
</button>
|
||||
) : (
|
||||
<button className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-[200px] drop-shadow-md bg-black hover:bg-gray-900 hover:cursor-pointer" onClick={startCourseUI}>Start Course</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const StyledBox = (props: any) => (
|
||||
<div className="p-3 pl-10 bg-white w-[100%] h-auto ring-1 ring-inset ring-gray-400/10 rounded-lg shadow-sm">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
|
||||
export default CourseClient;
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
import React, { FC, use, useEffect, useReducer } from 'react'
|
||||
import { revalidateTags, swrFetcher } from "@services/utils/ts/requests";
|
||||
import { getAPIUrl, getUriWithOrg } from '@services/config/config';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
|
||||
import Link from 'next/link';
|
||||
import CourseEdition from '../subpages/CourseEdition';
|
||||
import CourseContentEdition from '../subpages/CourseContentEdition';
|
||||
import ErrorUI from '@components/StyledElements/Error/Error';
|
||||
import { updateChaptersMetadata } from '@services/courses/chapters';
|
||||
import { Check, SaveAllIcon, Timer } from 'lucide-react';
|
||||
import Loading from '../../loading';
|
||||
import { updateCourse } from '@services/courses/courses';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
function CourseEditClient({ courseid, subpage, params }: { courseid: string, subpage: string, params: any }) {
|
||||
const { data: chapters_meta, error: chapters_meta_error, isLoading: chapters_meta_isloading } = useSWR(`${getAPIUrl()}chapters/meta/course_${courseid}`, swrFetcher);
|
||||
const { data: course, error: course_error, isLoading: course_isloading } = useSWR(`${getAPIUrl()}courses/course_${courseid}`, swrFetcher);
|
||||
const [courseChaptersMetadata, dispatchCourseChaptersMetadata] = useReducer(courseChaptersReducer, {});
|
||||
const [courseState, dispatchCourseMetadata] = useReducer(courseReducer, {});
|
||||
const [savedContent, dispatchSavedContent] = useReducer(savedContentReducer, true);
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
|
||||
function courseChaptersReducer(state: any, action: any) {
|
||||
switch (action.type) {
|
||||
case 'updated_chapter':
|
||||
// action will contain the entire state, just update the entire state
|
||||
return action.payload;
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
function courseReducer(state: any, action: any) {
|
||||
switch (action.type) {
|
||||
case 'updated_course':
|
||||
// action will contain the entire state, just update the entire state
|
||||
return action.payload;
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
function savedContentReducer(state: any, action: any) {
|
||||
switch (action.type) {
|
||||
case 'saved_content':
|
||||
return true;
|
||||
case 'unsaved_content':
|
||||
return false;
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCourse() {
|
||||
if (subpage.toString() === 'content') {
|
||||
await updateChaptersMetadata(courseid, courseChaptersMetadata)
|
||||
dispatchSavedContent({ type: 'saved_content' })
|
||||
await mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`)
|
||||
await revalidateTags(['courses'], params.params.orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
else if (subpage.toString() === 'general') {
|
||||
await updateCourse(courseid, courseState)
|
||||
dispatchSavedContent({ type: 'saved_content' })
|
||||
await mutate(`${getAPIUrl()}courses/course_${courseid}`)
|
||||
await revalidateTags(['courses'], params.params.orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (chapters_meta) {
|
||||
dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: chapters_meta })
|
||||
dispatchSavedContent({ type: 'saved_content' })
|
||||
}
|
||||
if (course) {
|
||||
dispatchCourseMetadata({ type: 'updated_course', payload: course })
|
||||
dispatchSavedContent({ type: 'saved_content' })
|
||||
}
|
||||
}, [chapters_meta, course])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='bg-white shadow-[0px_4px_16px_rgba(0,0,0,0.02)]'>
|
||||
<div className='max-w-screen-2xl mx-auto px-16 pt-5 tracking-tight'>
|
||||
{course_isloading && <div className='text-sm text-gray-500'>Loading...</div>}
|
||||
{course && <>
|
||||
<div className='flex items-center'><div className='info flex space-x-5 items-center grow'>
|
||||
<div className='flex'>
|
||||
<Link href={getUriWithOrg(params.params.orgslug, "") + `/course/${courseid}`}>
|
||||
<img className="w-[100px] h-[57px] rounded-md drop-shadow-md" src={`${getCourseThumbnailMediaDirectory(course.org_id, "course_" + courseid, course.thumbnail)}`} alt="" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col ">
|
||||
<div className='text-sm text-gray-500'>Edit Course</div>
|
||||
<div className='text-2xl font-bold first-letter:uppercase'>{course.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex space-x-5 items-center'>
|
||||
{savedContent ? <></> : <div className='text-gray-600 flex space-x-2 items-center antialiased'>
|
||||
<Timer size={15} />
|
||||
<div>
|
||||
Unsaved changes
|
||||
</div>
|
||||
|
||||
</div>}
|
||||
<div className={`' px-4 py-2 rounded-lg drop-shadow-md cursor-pointer flex space-x-2 items-center font-bold antialiased transition-all ease-linear ` + (savedContent ? 'bg-gray-600 text-white' : 'bg-black text-white border hover:bg-gray-900 ')
|
||||
} onClick={saveCourse}>
|
||||
|
||||
{savedContent ? <Check size={20} /> : <SaveAllIcon size={20} />}
|
||||
{savedContent ? <div className=''>Saved</div> : <div className=''>Save</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
<div className='flex space-x-5 pt-3 font-black text-sm'>
|
||||
<Link href={getUriWithOrg(params.params.orgslug, "") + `/course/${courseid}/edit/general`}>
|
||||
<div className={`py-2 w-16 text-center border-black transition-all ease-linear ${subpage.toString() === 'general' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>General</div>
|
||||
</Link>
|
||||
<Link href={getUriWithOrg(params.params.orgslug, "") + `/course/${courseid}/edit/content`}>
|
||||
<div className={`py-2 w-16 text-center border-black transition-all ease-linear ${subpage.toString() === 'content' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>Content</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CoursePageViewer dispatchSavedContent={dispatchSavedContent} courseState={courseState} courseChaptersMetadata={courseChaptersMetadata} dispatchCourseMetadata={dispatchCourseMetadata} dispatchCourseChaptersMetadata={dispatchCourseChaptersMetadata} subpage={subpage} courseid={courseid} orgslug={params.params.orgslug} />
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
const CoursePageViewer = ({ subpage, courseid, orgslug, dispatchCourseMetadata, dispatchCourseChaptersMetadata, courseChaptersMetadata, dispatchSavedContent, courseState }: { subpage: string, courseid: string, orgslug: string, dispatchCourseChaptersMetadata: React.Dispatch<any>, dispatchCourseMetadata: React.Dispatch<any>, dispatchSavedContent: React.Dispatch<any>, courseChaptersMetadata: any, courseState: any }) => {
|
||||
if (subpage.toString() === 'general' && Object.keys(courseState).length !== 0) {
|
||||
return <CourseEdition data={courseState} dispatchCourseMetadata={dispatchCourseMetadata} dispatchSavedContent={dispatchSavedContent} />
|
||||
}
|
||||
else if (subpage.toString() === 'content' && Object.keys(courseChaptersMetadata).length !== 0) {
|
||||
return <CourseContentEdition data={courseChaptersMetadata} dispatchSavedContent={dispatchSavedContent} dispatchCourseChaptersMetadata={dispatchCourseChaptersMetadata} courseid={courseid} orgslug={orgslug} />
|
||||
}
|
||||
else if (subpage.toString() === 'content' || subpage.toString() === 'general') {
|
||||
return <Loading />
|
||||
}
|
||||
else {
|
||||
return <ErrorUI />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CourseEditClient
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import CourseEditClient from "./edit";
|
||||
import { getCourseMetadataWithAuthHeader } from "@services/courses/courses";
|
||||
import { cookies } from "next/headers";
|
||||
import { Metadata } from 'next';
|
||||
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
const course_meta = await getCourseMetadataWithAuthHeader(params.courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
|
||||
|
||||
return {
|
||||
title: `Edit Course - ` + course_meta.course.name,
|
||||
description: course_meta.course.mini_description,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function CourseEdit(params: any) {
|
||||
let subpage = params.params.subpage ? params.params.subpage : 'general';
|
||||
return (
|
||||
<>
|
||||
<CourseEditClient params={params} subpage={subpage} courseid={params.params.courseid} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default CourseEdit;
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { DragDropContext, Droppable } from "react-beautiful-dnd";
|
||||
import Chapter from "@components/Pages/CourseEdit/Draggables/Chapter";
|
||||
import { createChapter, deleteChapter, getCourseChaptersMetadata, updateChaptersMetadata } from "@services/courses/chapters";
|
||||
import { useRouter } from "next/navigation";
|
||||
import NewChapterModal from "@components/Objects/Modals/Chapters/NewChapter";
|
||||
import NewActivityModal from "@components/Objects/Modals/Activities/Create/NewActivity";
|
||||
import { createActivity, createFileActivity, createExternalVideoActivity } from "@services/courses/activities";
|
||||
import { getOrganizationContextInfo, getOrganizationContextInfoWithoutCredentials } from "@services/organizations/orgs";
|
||||
import Modal from "@components/StyledElements/Modal/Modal";
|
||||
import { denyAccessToUser } from "@services/utils/react/middlewares/views";
|
||||
import { Folders, Hexagon, SaveIcon } from "lucide-react";
|
||||
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
|
||||
import { revalidateTags, swrFetcher } from "@services/utils/ts/requests";
|
||||
import { mutate } from "swr";
|
||||
import { getAPIUrl } from "@services/config/config";
|
||||
|
||||
function CourseContentEdition(props: any) {
|
||||
const router = useRouter();
|
||||
// Initial Course State
|
||||
const data = props.data;
|
||||
|
||||
// New Chapter Modal State
|
||||
const [newChapterModal, setNewChapterModal] = useState(false) as any;
|
||||
// New Activity Modal State
|
||||
const [newActivityModal, setNewActivityModal] = useState(false) as any;
|
||||
const [newActivityModalData, setNewActivityModalData] = useState("") as any;
|
||||
|
||||
// Check window availability
|
||||
const [winReady, setwinReady] = useState(false);
|
||||
const courseid = props.courseid;
|
||||
const orgslug = props.orgslug;
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setwinReady(true);
|
||||
}, [courseid, orgslug]);
|
||||
|
||||
// get a list of chapters order by chapter order
|
||||
const getChapters = () => {
|
||||
const chapterOrder = data.chapterOrder ? data.chapterOrder : [];
|
||||
return chapterOrder.map((chapterId: any) => {
|
||||
const chapter = data.chapters[chapterId];
|
||||
let activities = [];
|
||||
if (data.activities) {
|
||||
activities = chapter.activityIds.map((activityId: any) => data.activities[activityId])
|
||||
? chapter.activityIds.map((activityId: any) => data.activities[activityId])
|
||||
: [];
|
||||
}
|
||||
return {
|
||||
list: {
|
||||
chapter: chapter,
|
||||
activities: activities,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Submit new chapter
|
||||
const submitChapter = async (chapter: any) => {
|
||||
await createChapter(chapter, courseid);
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`);
|
||||
// await getCourseChapters();
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
setNewChapterModal(false);
|
||||
};
|
||||
|
||||
// Submit new activity
|
||||
const submitActivity = async (activity: any) => {
|
||||
let org = await getOrganizationContextInfoWithoutCredentials(orgslug, { revalidate: 1800 });
|
||||
await updateChaptersMetadata(courseid, data);
|
||||
await createActivity(activity, activity.chapterId, org.org_id);
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`);
|
||||
// await getCourseChapters();
|
||||
setNewActivityModal(false);
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Submit File Upload
|
||||
const submitFileActivity = async (file: any, type: any, activity: any, chapterId: string) => {
|
||||
await updateChaptersMetadata(courseid, data);
|
||||
await createFileActivity(file, type, activity, chapterId);
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`);
|
||||
// await getCourseChapters();
|
||||
setNewActivityModal(false);
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Submit YouTube Video Upload
|
||||
const submitExternalVideo = async (external_video_data: any, activity: any, chapterId: string) => {
|
||||
await updateChaptersMetadata(courseid, data);
|
||||
await createExternalVideoActivity(external_video_data, activity, chapterId);
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`);
|
||||
// await getCourseChapters();
|
||||
setNewActivityModal(false);
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const deleteChapterUI = async (chapterId: any) => {
|
||||
|
||||
await deleteChapter(chapterId);
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`);
|
||||
// await getCourseChapters();
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const updateChapters = () => {
|
||||
updateChaptersMetadata(courseid, data);
|
||||
revalidateTags(['courses'], orgslug);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
/*
|
||||
Modals
|
||||
*/
|
||||
|
||||
const openNewActivityModal = async (chapterId: any) => {
|
||||
setNewActivityModal(true);
|
||||
setNewActivityModalData(chapterId);
|
||||
};
|
||||
|
||||
// Close new chapter modal
|
||||
const closeNewChapterModal = () => {
|
||||
setNewChapterModal(false);
|
||||
};
|
||||
|
||||
const closeNewActivityModal = () => {
|
||||
setNewActivityModal(false);
|
||||
};
|
||||
|
||||
/*
|
||||
Drag and drop functions
|
||||
|
||||
*/
|
||||
const onDragEnd = async (result: any) => {
|
||||
const { destination, source, draggableId, type } = result;
|
||||
|
||||
|
||||
// check if the activity is dropped outside the droppable area
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the activity is dropped in the same place
|
||||
if (destination.droppableId === source.droppableId && destination.index === source.index) {
|
||||
return;
|
||||
}
|
||||
//////////////////////////// CHAPTERS ////////////////////////////
|
||||
if (type === "chapter") {
|
||||
const newChapterOrder = Array.from(data.chapterOrder);
|
||||
newChapterOrder.splice(source.index, 1);
|
||||
newChapterOrder.splice(destination.index, 0, draggableId);
|
||||
|
||||
const newState = {
|
||||
...data,
|
||||
chapterOrder: newChapterOrder,
|
||||
};
|
||||
|
||||
props.dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: newState })
|
||||
props.dispatchSavedContent({ type: 'unsaved_content' })
|
||||
//setData(newState);
|
||||
return;
|
||||
}
|
||||
|
||||
//////////////////////// ACTIVITIES IN SAME CHAPTERS ////////////////////////////
|
||||
// check if the activity is dropped in the same chapter
|
||||
const start = data.chapters[source.droppableId];
|
||||
const finish = data.chapters[destination.droppableId];
|
||||
|
||||
// check if the activity is dropped in the same chapter
|
||||
if (start === finish) {
|
||||
// create new arrays for chapters and activities
|
||||
const chapter = data.chapters[source.droppableId];
|
||||
const newActivityIds = Array.from(chapter.activityIds);
|
||||
|
||||
// remove the activity from the old position
|
||||
newActivityIds.splice(source.index, 1);
|
||||
|
||||
// add the activity to the new position
|
||||
newActivityIds.splice(destination.index, 0, draggableId);
|
||||
|
||||
const newChapter = {
|
||||
...chapter,
|
||||
activityIds: newActivityIds,
|
||||
};
|
||||
|
||||
const newState = {
|
||||
...data,
|
||||
chapters: {
|
||||
...data.chapters,
|
||||
[newChapter.id]: newChapter,
|
||||
},
|
||||
};
|
||||
props.dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: newState })
|
||||
props.dispatchSavedContent({ type: 'unsaved_content' })
|
||||
//setData(newState);
|
||||
return;
|
||||
}
|
||||
|
||||
//////////////////////// ACTIVITIES IN DIFF CHAPTERS ////////////////////////////
|
||||
// check if the activity is dropped in a different chapter
|
||||
if (start !== finish) {
|
||||
// create new arrays for chapters and activities
|
||||
const startChapterActivityIds = Array.from(start.activityIds);
|
||||
|
||||
// remove the activity from the old position
|
||||
startChapterActivityIds.splice(source.index, 1);
|
||||
const newStart = {
|
||||
...start,
|
||||
activityIds: startChapterActivityIds,
|
||||
};
|
||||
|
||||
// add the activity to the new position within the chapter
|
||||
const finishChapterActivityIds = Array.from(finish.activityIds);
|
||||
finishChapterActivityIds.splice(destination.index, 0, draggableId);
|
||||
const newFinish = {
|
||||
...finish,
|
||||
activityIds: finishChapterActivityIds,
|
||||
};
|
||||
|
||||
const newState = {
|
||||
...data,
|
||||
chapters: {
|
||||
...data.chapters,
|
||||
[newStart.id]: newStart,
|
||||
[newFinish.id]: newFinish,
|
||||
},
|
||||
};
|
||||
|
||||
props.dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: newState })
|
||||
props.dispatchSavedContent({ type: 'unsaved_content' })
|
||||
//setData(newState);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className=""
|
||||
>
|
||||
<GeneralWrapperStyled>
|
||||
<Modal
|
||||
isDialogOpen={newActivityModal}
|
||||
onOpenChange={setNewActivityModal}
|
||||
minHeight="no-min"
|
||||
addDefCloseButton={false}
|
||||
dialogContent={<NewActivityModal
|
||||
closeModal={closeNewActivityModal}
|
||||
submitFileActivity={submitFileActivity}
|
||||
submitExternalVideo={submitExternalVideo}
|
||||
submitActivity={submitActivity}
|
||||
chapterId={newActivityModalData}
|
||||
></NewActivityModal>}
|
||||
dialogTitle="Create Activity"
|
||||
dialogDescription="Choose between types of activities to add to the course"
|
||||
|
||||
/>
|
||||
{winReady && (
|
||||
<div className="flex flex-col">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable key="chapters" droppableId="chapters" type="chapter">
|
||||
{(provided) => (
|
||||
<>
|
||||
<div key={"chapters"} {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{getChapters().map((info: any, index: any) => (
|
||||
<>
|
||||
<Chapter
|
||||
orgslug={orgslug}
|
||||
courseid={courseid}
|
||||
openNewActivityModal={openNewActivityModal}
|
||||
deleteChapter={deleteChapterUI}
|
||||
key={index}
|
||||
info={info}
|
||||
index={index}
|
||||
></Chapter>
|
||||
</>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<Modal
|
||||
isDialogOpen={newChapterModal}
|
||||
onOpenChange={setNewChapterModal}
|
||||
minHeight="sm"
|
||||
dialogContent={<NewChapterModal
|
||||
closeModal={closeNewChapterModal}
|
||||
submitChapter={submitChapter}
|
||||
></NewChapterModal>}
|
||||
dialogTitle="Create chapter"
|
||||
dialogDescription="Add a new chapter to the course"
|
||||
dialogTrigger={
|
||||
<div className="flex max-w-7xl bg-black text-sm shadow rounded-md items-center text-white justify-center mx-auto space-x-2 p-3 w-72 hover:bg-gray-900 hover:cursor-pointer">
|
||||
<Hexagon size={16} />
|
||||
<div>Add chapter +</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</GeneralWrapperStyled >
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default CourseContentEdition;
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input, Textarea } from '@components/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { useFormik } from 'formik';
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
|
||||
if (!values.name) {
|
||||
errors.name = 'Required';
|
||||
}
|
||||
|
||||
if (values.name.length > 100) {
|
||||
errors.name = 'Must be 80 characters or less';
|
||||
}
|
||||
|
||||
if (!values.mini_description) {
|
||||
errors.mini_description = 'Required';
|
||||
}
|
||||
|
||||
if (values.mini_description.length > 200) {
|
||||
errors.mini_description = 'Must be 200 characters or less';
|
||||
}
|
||||
|
||||
if (!values.description) {
|
||||
errors.description = 'Required';
|
||||
|
||||
}
|
||||
|
||||
if (values.description.length > 1000) {
|
||||
errors.description = 'Must be 1000 characters or less';
|
||||
}
|
||||
|
||||
|
||||
if (!values.learnings) {
|
||||
errors.learnings = 'Required';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
function CourseEdition(props: any) {
|
||||
const [error, setError] = React.useState('');
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: String(props.data.name),
|
||||
mini_description: String(props.data.mini_description),
|
||||
description: String(props.data.description),
|
||||
learnings: String(props.data.learnings),
|
||||
},
|
||||
validate,
|
||||
onSubmit: async values => {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
// This code will run whenever form values are updated
|
||||
if (formik.values !== formik.initialValues) {
|
||||
props.dispatchSavedContent({ type: 'unsaved_content' });
|
||||
const updatedCourse = {
|
||||
...props.data,
|
||||
name: formik.values.name,
|
||||
mini_description: formik.values.mini_description,
|
||||
description: formik.values.description,
|
||||
learnings: formik.values.learnings.split(", "),
|
||||
};
|
||||
props.dispatchCourseMetadata({ type: 'updated_course', payload: updatedCourse });
|
||||
}
|
||||
}, [formik.values, formik.initialValues]);
|
||||
|
||||
|
||||
return (
|
||||
<div className='max-w-screen-2xl mx-auto px-16 pt-5 tracking-tight'>
|
||||
<div className="login-form">
|
||||
{error && (
|
||||
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
|
||||
<AlertTriangle size={18} />
|
||||
<div className="font-bold text-sm">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<FormField name="name">
|
||||
<FormLabelAndMessage label='Name' message={formik.errors.name} />
|
||||
<Form.Control asChild>
|
||||
<Input style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.name} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="mini_description">
|
||||
<FormLabelAndMessage label='Mini description' message={formik.errors.mini_description} />
|
||||
<Form.Control asChild>
|
||||
<Input style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.mini_description} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="description">
|
||||
<FormLabelAndMessage label='Description' message={formik.errors.description} />
|
||||
<Form.Control asChild>
|
||||
<Textarea style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.description} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="learnings">
|
||||
<FormLabelAndMessage label='Learnings (Separated by , )' message={formik.errors.learnings} />
|
||||
<Form.Control asChild>
|
||||
<Textarea placeholder='Science, Design, Architecture' style={{ backgroundColor: "white" }} onChange={formik.handleChange} value={formik.values.learnings} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
</FormLayout>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CourseEdition
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
'use client'; // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ErrorUI></ErrorUI>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
|
||||
export default function Loading() {
|
||||
// Or a custom loading skeleton component
|
||||
return (
|
||||
<PageLoading></PageLoading>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react'
|
||||
import CourseClient from './course'
|
||||
import { cookies } from 'next/headers';
|
||||
import { getCourseMetadataWithAuthHeader } from '@services/courses/courses';
|
||||
import { getOrganizationContextInfo } from '@services/organizations/orgs';
|
||||
import { Metadata } from 'next';
|
||||
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from '@services/auth/auth';
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
const course_meta = await getCourseMetadataWithAuthHeader(params.courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
|
||||
|
||||
|
||||
// SEO
|
||||
return {
|
||||
title: course_meta.course.name + ` — ${org.name}`,
|
||||
description: course_meta.course.mini_description,
|
||||
keywords: course_meta.course.learnings,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
}
|
||||
},
|
||||
openGraph: {
|
||||
title: course_meta.course.name + ` — ${org.name}`,
|
||||
description: course_meta.course.mini_description,
|
||||
type: 'article',
|
||||
publishedTime: course_meta.course.creationDate,
|
||||
tags: course_meta.course.learnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const CoursePage = async (params: any) => {
|
||||
const cookieStore = cookies();
|
||||
const courseid = params.params.courseid
|
||||
const orgslug = params.params.orgslug;
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
const course_meta = await getCourseMetadataWithAuthHeader(courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CourseClient courseid={courseid} orgslug={orgslug} course={course_meta} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoursePage
|
||||
107
apps/web/app/orgs/[orgslug]/(withmenu)/courses/courses.tsx
Normal file
107
apps/web/app/orgs/[orgslug]/(withmenu)/courses/courses.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
'use client';
|
||||
import CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse';
|
||||
import Modal from '@components/StyledElements/Modal/Modal';
|
||||
import React from 'react'
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper';
|
||||
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle';
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement';
|
||||
import CourseThumbnail from '@components/Objects/Other/CourseThumbnail';
|
||||
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton';
|
||||
|
||||
interface CourseProps {
|
||||
orgslug: string;
|
||||
courses: any;
|
||||
org_id: string;
|
||||
}
|
||||
|
||||
function Courses(props: CourseProps) {
|
||||
const orgslug = props.orgslug;
|
||||
const courses = props.courses;
|
||||
const searchParams = useSearchParams();
|
||||
const isCreatingCourse = searchParams.get('new') ? true : false;
|
||||
const [newCourseModal, setNewCourseModal] = React.useState(isCreatingCourse);
|
||||
|
||||
async function closeNewCourseModal() {
|
||||
setNewCourseModal(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GeneralWrapperStyled>
|
||||
|
||||
<div className='flex flex-wrap justify-between'>
|
||||
<TypeOfContentTitle title="Courses" type="cou" />
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={props.org_id}>
|
||||
<Modal
|
||||
isDialogOpen={newCourseModal}
|
||||
onOpenChange={setNewCourseModal}
|
||||
minHeight="md"
|
||||
dialogContent={<CreateCourseModal
|
||||
closeModal={closeNewCourseModal}
|
||||
orgslug={orgslug}
|
||||
></CreateCourseModal>}
|
||||
dialogTitle="Create Course"
|
||||
dialogDescription="Create a new course"
|
||||
dialogTrigger={
|
||||
|
||||
<button>
|
||||
<NewCourseButton />
|
||||
</button>}
|
||||
/>
|
||||
</AuthenticatedClientElement>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
{courses.map((course: any) => (
|
||||
<div className="px-3" key={course.course_id}>
|
||||
<CourseThumbnail course={course} orgslug={orgslug} />
|
||||
</div>
|
||||
))}
|
||||
{courses.length == 0 &&
|
||||
<div className="flex mx-auto h-[400px]">
|
||||
<div className="flex flex-col justify-center text-center items-center space-y-5">
|
||||
<div className='mx-auto'>
|
||||
<svg width="120" height="120" viewBox="0 0 295 295" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect opacity="0.51" x="10" y="10" width="275" height="275" rx="75" stroke="#4B5564" strokeOpacity="0.15" strokeWidth="20" />
|
||||
<path d="M135.8 200.8V130L122.2 114.6L135.8 110.4V102.8L122.2 87.4L159.8 76V200.8L174.6 218H121L135.8 200.8Z" fill="#4B5564" fillOpacity="0.08" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
<h1 className="text-3xl font-bold text-gray-600">No courses yet</h1>
|
||||
<p className="text-lg text-gray-400">Create a course to add content</p>
|
||||
</div>
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={props.org_id}>
|
||||
<Modal
|
||||
isDialogOpen={newCourseModal}
|
||||
onOpenChange={setNewCourseModal}
|
||||
minHeight="md"
|
||||
dialogContent={<CreateCourseModal
|
||||
closeModal={closeNewCourseModal}
|
||||
orgslug={orgslug}
|
||||
></CreateCourseModal>}
|
||||
dialogTitle="Create Course"
|
||||
dialogDescription="Create a new course"
|
||||
dialogTrigger={
|
||||
<button>
|
||||
<NewCourseButton />
|
||||
</button>}
|
||||
/>
|
||||
</AuthenticatedClientElement>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
</GeneralWrapperStyled>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export default Courses
|
||||
23
apps/web/app/orgs/[orgslug]/(withmenu)/courses/error.tsx
Normal file
23
apps/web/app/orgs/[orgslug]/(withmenu)/courses/error.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client'; // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ErrorUI></ErrorUI>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
|
||||
export default function Loading() {
|
||||
// Or a custom loading skeleton component
|
||||
return (
|
||||
<PageLoading></PageLoading>
|
||||
)
|
||||
|
||||
}
|
||||
60
apps/web/app/orgs/[orgslug]/(withmenu)/courses/page.tsx
Normal file
60
apps/web/app/orgs/[orgslug]/(withmenu)/courses/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
|
||||
import React from "react";
|
||||
import Courses from "./courses";
|
||||
import { getOrgCoursesWithAuthHeader } from "@services/courses/courses";
|
||||
import { Metadata } from "next";
|
||||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
// SEO
|
||||
return {
|
||||
title: "Courses — " + org.name,
|
||||
description: org.description,
|
||||
keywords: `${org.name}, ${org.description}, courses, learning, education, online learning, edu, online courses, ${org.name} courses`,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
}
|
||||
},
|
||||
openGraph: {
|
||||
title: "Courses — " + org.name,
|
||||
description: org.description,
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CoursesPage = async (params: any) => {
|
||||
const orgslug = params.params.orgslug;
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
const cookieStore = cookies();
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
const courses = await getOrgCoursesWithAuthHeader(orgslug, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Courses org_id={org.org_id} orgslug={orgslug} courses={courses} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursesPage;
|
||||
|
||||
23
apps/web/app/orgs/[orgslug]/(withmenu)/error.tsx
Normal file
23
apps/web/app/orgs/[orgslug]/(withmenu)/error.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client'; // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ErrorUI></ErrorUI>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/web/app/orgs/[orgslug]/(withmenu)/layout.tsx
Normal file
14
apps/web/app/orgs/[orgslug]/(withmenu)/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import "@styles/globals.css";
|
||||
import { Menu } from "@components/Objects/Menu/Menu";
|
||||
import AuthProvider from "@components/Security/AuthProvider";
|
||||
|
||||
export default function RootLayout({ children, params }: { children: React.ReactNode , params :any}) {
|
||||
return (
|
||||
<>
|
||||
<AuthProvider>
|
||||
<Menu orgslug={params?.orgslug}></Menu>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
apps/web/app/orgs/[orgslug]/(withmenu)/loading.tsx
Normal file
8
apps/web/app/orgs/[orgslug]/(withmenu)/loading.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<PageLoading></PageLoading>
|
||||
)
|
||||
|
||||
}
|
||||
146
apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx
Normal file
146
apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
import { Metadata } from 'next';
|
||||
import { getUriWithOrg } from "@services/config/config";
|
||||
import { getOrgCoursesWithAuthHeader } from "@services/courses/courses";
|
||||
import Link from "next/link";
|
||||
import { getOrgCollectionsWithAuthHeader } from "@services/courses/collections";
|
||||
import { getOrganizationContextInfo } from '@services/organizations/orgs';
|
||||
import { cookies } from 'next/headers';
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper';
|
||||
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle';
|
||||
import { getAccessTokenFromRefreshTokenCookie } from '@services/auth/auth';
|
||||
import CourseThumbnail from '@components/Objects/Other/CourseThumbnail';
|
||||
import CollectionThumbnail from '@components/Objects/Other/CollectionThumbnail';
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement';
|
||||
import { Plus, PlusCircle } from 'lucide-react';
|
||||
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton';
|
||||
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton';
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
// SEO
|
||||
return {
|
||||
title: `Home — ${org.name}`,
|
||||
description: org.description,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
}
|
||||
},
|
||||
openGraph: {
|
||||
title: `Home — ${org.name}`,
|
||||
description: org.description,
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const OrgHomePage = async (params: any) => {
|
||||
const orgslug = params.params.orgslug;
|
||||
const cookieStore = cookies();
|
||||
|
||||
const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore)
|
||||
const courses = await getOrgCoursesWithAuthHeader(orgslug, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null);
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
const org_id = org.org_id;
|
||||
const collections = await getOrgCollectionsWithAuthHeader(org.org_id, access_token ? access_token : null, { revalidate: 0, tags: ['courses'] });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GeneralWrapperStyled>
|
||||
{/* Collections */}
|
||||
<div className='flex items-center '>
|
||||
<div className='flex grow'>
|
||||
<TypeOfContentTitle title="Collections" type="col" />
|
||||
</div>
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={org_id}>
|
||||
<Link href={getUriWithOrg(orgslug, "/collections/new")}>
|
||||
<NewCollectionButton />
|
||||
</Link>
|
||||
</AuthenticatedClientElement>
|
||||
</div>
|
||||
<div className="home_collections flex flex-wrap">
|
||||
{collections.map((collection: any) => (
|
||||
<div className="flex flex-col py-3 px-3" key={collection.collection_id}>
|
||||
<CollectionThumbnail collection={collection} orgslug={orgslug} org_id={org.org_id} />
|
||||
</div>
|
||||
))}
|
||||
{collections.length == 0 &&
|
||||
<div className="flex mx-auto h-[100px]">
|
||||
<div className="flex flex-col justify-center text-center items-center space-y-3">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className='mx-auto'>
|
||||
<svg width="50" height="50" viewBox="0 0 295 295" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect opacity="0.51" x="10" y="10" width="275" height="275" rx="75" stroke="#4B5564" strokeOpacity="0.15" strokeWidth="20" />
|
||||
<path d="M135.8 200.8V130L122.2 114.6L135.8 110.4V102.8L122.2 87.4L159.8 76V200.8L174.6 218H121L135.8 200.8Z" fill="#4B5564" fillOpacity="0.08" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
<h1 className="text-xl font-bold text-gray-600">No collections yet</h1>
|
||||
<p className="text-md text-gray-400">Create a collection to group courses together</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Courses */}
|
||||
<div className='h-5'></div>
|
||||
<div className='flex items-center '>
|
||||
<div className='flex grow'>
|
||||
<TypeOfContentTitle title="Courses" type="cou" />
|
||||
</div>
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={org_id}>
|
||||
<Link href={getUriWithOrg(orgslug, "/courses?new=true")}>
|
||||
<NewCourseButton />
|
||||
</Link>
|
||||
</AuthenticatedClientElement>
|
||||
</div>
|
||||
<div className="home_courses flex flex-wrap">
|
||||
{courses.map((course: any) => (
|
||||
<div className="py-3 px-3" key={course.course_id}>
|
||||
<CourseThumbnail course={course} orgslug={orgslug} />
|
||||
</div>
|
||||
))}
|
||||
{courses.length == 0 &&
|
||||
<div className="flex mx-auto h-[300px]">
|
||||
<div className="flex flex-col justify-center text-center items-center space-y-3">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className='mx-auto'>
|
||||
<svg width="50" height="50" viewBox="0 0 295 295" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect opacity="0.51" x="10" y="10" width="275" height="275" rx="75" stroke="#4B5564" strokeOpacity="0.15" strokeWidth="20" />
|
||||
<path d="M135.8 200.8V130L122.2 114.6L135.8 110.4V102.8L122.2 87.4L159.8 76V200.8L174.6 218H121L135.8 200.8Z" fill="#4B5564" fillOpacity="0.08" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
<h1 className="text-xl font-bold text-gray-600">No courses yet</h1>
|
||||
<p className="text-md text-gray-400">Create a course to add content</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default OrgHomePage;
|
||||
33
apps/web/app/orgs/[orgslug]/(withmenu)/trail/page.tsx
Normal file
33
apps/web/app/orgs/[orgslug]/(withmenu)/trail/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from "react";
|
||||
import { Metadata } from "next";
|
||||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import Trail from "./trail";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
return {
|
||||
title: "Trail — " + org.name,
|
||||
description: 'Check your progress using trail and easily navigate through your courses.',
|
||||
};
|
||||
}
|
||||
|
||||
const TrailPage = async (params: any) => {
|
||||
let orgslug = params.params.orgslug;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Trail orgslug={orgslug} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrailPage;
|
||||
43
apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx
Normal file
43
apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
import PageLoading from "@components/Objects/Loaders/PageLoading";
|
||||
import TrailCourseElement from "@components/Pages/Trail/TrailCourseElement";
|
||||
import TypeOfContentTitle from "@components/StyledElements/Titles/TypeOfContentTitle";
|
||||
import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper";
|
||||
import { getAPIUrl } from "@services/config/config";
|
||||
import { removeCourse } from "@services/courses/activity";
|
||||
import { revalidateTags, swrFetcher } from "@services/utils/ts/requests";
|
||||
import React from "react";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
function Trail(params: any) {
|
||||
let orgslug = params.orgslug;
|
||||
const { data: trail, error: error } = useSWR(`${getAPIUrl()}trail/org_slug/${orgslug}/trail`, swrFetcher);
|
||||
|
||||
|
||||
return (
|
||||
<GeneralWrapperStyled>
|
||||
<TypeOfContentTitle title="Trail" type="tra" />
|
||||
{!trail ? (
|
||||
<PageLoading></PageLoading>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{trail.courses.map((course: any) => (
|
||||
!course.masked ? (
|
||||
<TrailCourseElement key={trail.trail_id} orgslug={orgslug} course={course} />
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</GeneralWrapperStyled>
|
||||
);
|
||||
}
|
||||
|
||||
export default Trail;
|
||||
|
||||
|
||||
|
||||
|
||||
11
apps/web/app/orgs/[orgslug]/layout.tsx
Normal file
11
apps/web/app/orgs/[orgslug]/layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
import "@styles/globals.css";
|
||||
|
||||
export default function RootLayout({ children, params }: { children: React.ReactNode , params:any}) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
apps/web/app/orgs/[orgslug]/login/login.tsx
Normal file
139
apps/web/app/orgs/[orgslug]/login/login.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
import learnhouseIcon from "public/learnhouse_bigicon_1.png";
|
||||
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input } from '@components/StyledElements/Form/Form'
|
||||
import Image from 'next/image';
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { useFormik } from 'formik';
|
||||
import { getOrgLogoMediaDirectory } from "@services/media/media";
|
||||
import { BarLoader } from "react-spinners";
|
||||
import React from "react";
|
||||
import { loginAndGetToken } from "@services/auth/auth";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getUriWithOrg } from "@services/config/config";
|
||||
|
||||
interface LoginClientProps {
|
||||
org: any;
|
||||
}
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
|
||||
if (!values.email) {
|
||||
errors.email = 'Required';
|
||||
}
|
||||
else if (
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
|
||||
) {
|
||||
errors.email = 'Invalid email address';
|
||||
}
|
||||
|
||||
if (!values.password) {
|
||||
errors.password = 'Required';
|
||||
}
|
||||
else if (values.password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const LoginClient = (props: LoginClientProps) => {
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState('');
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
validate,
|
||||
onSubmit: async values => {
|
||||
setIsSubmitting(true);
|
||||
let res = await loginAndGetToken(values.email, values.password);
|
||||
let message = await res.json();
|
||||
if (res.status == 200) {
|
||||
router.push(`/`);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
else if (res.status == 401 || res.status == 400 || res.status == 404 || res.status == 409) {
|
||||
setError(message.detail);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
else {
|
||||
setError("Something went wrong");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className='grid grid-flow-col justify-stretch h-screen'>
|
||||
<div className="right-login-part" style={{ background: "linear-gradient(041.61deg, #202020 7.15%, #000000 90.96%)" }} >
|
||||
<div className='login-topbar m-10'>
|
||||
<Link prefetch href={getUriWithOrg(props.org.slug, "/")}>
|
||||
<Image quality={100} width={30} height={30} src={learnhouseIcon} alt="" />
|
||||
</Link></div>
|
||||
<div className="ml-10 h-4/6 flex flex-row text-white">
|
||||
<div className="m-auto flex space-x-4 items-center flex-wrap">
|
||||
<div>Login to </div>
|
||||
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" >
|
||||
{props.org?.logo ? (
|
||||
<img
|
||||
src={`${getOrgLogoMediaDirectory(props.org.org_id, props.org?.logo)}`}
|
||||
alt="Learnhouse"
|
||||
style={{ width: "auto", height: 70 }}
|
||||
className="rounded-md shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-bold text-xl">{props.org?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="left-login-part bg-white flex flex-row">
|
||||
<div className="login-form m-auto w-72">
|
||||
{error && (
|
||||
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
|
||||
<AlertTriangle size={18} />
|
||||
<div className="font-bold text-sm">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<FormField name="email">
|
||||
<FormLabelAndMessage label='Email' message={formik.errors.email} />
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
{/* for password */}
|
||||
<FormField name="password">
|
||||
<FormLabelAndMessage label='Password' message={formik.errors.password} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
|
||||
<div className="flex py-4">
|
||||
<Form.Submit asChild>
|
||||
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" >
|
||||
{isSubmitting ? "Loading..."
|
||||
: "Login"}
|
||||
</button>
|
||||
</Form.Submit>
|
||||
</div>
|
||||
|
||||
</FormLayout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginClient
|
||||
|
||||
35
apps/web/app/orgs/[orgslug]/login/page.tsx
Normal file
35
apps/web/app/orgs/[orgslug]/login/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import LoginClient from "./login";
|
||||
import { Metadata } from 'next';
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
const orgslug = params.orgslug;
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
return {
|
||||
title: 'Login' + ` — ${org.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
const Login = async (params: any) => {
|
||||
const orgslug = params.params.orgslug;
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LoginClient org={org}></LoginClient>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export default Login;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import { Metadata } from "next";
|
||||
import PasswordsClient from "./passwords";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
return {
|
||||
title: `Settings: Passwords — ${org.name}`,
|
||||
description: org.description,
|
||||
};
|
||||
}
|
||||
|
||||
function SettingsProfilePasswordsPage() {
|
||||
return (
|
||||
<>
|
||||
<PasswordsClient></PasswordsClient>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsProfilePasswordsPage
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
import { AuthContext } from '@components/Security/AuthProvider';
|
||||
import React, { useEffect } from 'react'
|
||||
import { Formik, Form, Field, ErrorMessage } from 'formik';
|
||||
import { updatePassword } from '@services/settings/password';
|
||||
|
||||
|
||||
function PasswordsClient() {
|
||||
const auth: any = React.useContext(AuthContext);
|
||||
|
||||
|
||||
|
||||
const updatePasswordUI = async (values: any) => {
|
||||
let user_id = auth.userInfo.user_object.user_id;
|
||||
await updatePassword(user_id, values)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
{auth.isAuthenticated && (
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold'>Account Password</h1>
|
||||
<br /><br />
|
||||
|
||||
<Formik
|
||||
initialValues={{ old_password: '', new_password: '' }}
|
||||
onSubmit={(values, { setSubmitting }) => {
|
||||
setTimeout(() => {
|
||||
alert(JSON.stringify(values, null, 2));
|
||||
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"
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordsClient
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import { Metadata } from "next";
|
||||
import ProfileClient from "./profile";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
return {
|
||||
title: `Settings: Profile — ${org.name}`,
|
||||
description: org.description,
|
||||
};
|
||||
}
|
||||
|
||||
function SettingsProfilePage() {
|
||||
return <ProfileClient></ProfileClient>
|
||||
}
|
||||
|
||||
export default SettingsProfilePage
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
import { AuthContext } from '@components/Security/AuthProvider';
|
||||
import React, { useEffect } from 'react'
|
||||
import { Formik, Form, Field, ErrorMessage } from 'formik';
|
||||
import { updateProfile } from '@services/settings/profile';
|
||||
|
||||
function ProfileClient() {
|
||||
const auth: any = React.useContext(AuthContext);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
{auth.isAuthenticated && (
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold'>Profile Settings</h1>
|
||||
<br /><br />
|
||||
|
||||
<Formik
|
||||
initialValues={auth.userInfo.user_object}
|
||||
|
||||
onSubmit={(values, { setSubmitting }) => {
|
||||
setTimeout(() => {
|
||||
alert(JSON.stringify(values, null, 2));
|
||||
setSubmitting(false);
|
||||
updateProfile(values)
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className="max-w-md">
|
||||
<label className="block mb-2 font-bold" htmlFor="full_name">
|
||||
Full 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="textarea"
|
||||
name="full_name"
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<label className="block mb-2 font-bold" htmlFor="bio">
|
||||
Bio
|
||||
</label>
|
||||
<Field
|
||||
as="textarea"
|
||||
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
type="textarea"
|
||||
name="bio"
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileClient
|
||||
15
apps/web/app/orgs/[orgslug]/settings/head.tsx
Normal file
15
apps/web/app/orgs/[orgslug]/settings/head.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createStitches } from '@stitches/react';
|
||||
|
||||
export const { getCssText } = createStitches();
|
||||
|
||||
export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<title>Settings</title>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<style id="stitches" dangerouslySetInnerHTML={{ __html: getCssText() }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
122
apps/web/app/orgs/[orgslug]/settings/layout.tsx
Normal file
122
apps/web/app/orgs/[orgslug]/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
import React, { createContext, useState } from 'react'
|
||||
import { styled } from '@stitches/react';
|
||||
import Link from 'next/link';
|
||||
import LearnHouseWhiteLogo from '@public/learnhouse_text_white.png';
|
||||
import AuthProvider, { AuthContext } from '@components/Security/AuthProvider';
|
||||
import Avvvatars from 'avvvatars-react';
|
||||
import Image from 'next/image';
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement';
|
||||
import { getOrganizationContextInfo } from '@services/organizations/orgs';
|
||||
|
||||
async function SettingsLayout({ children, params }: { children: React.ReactNode, params: any }) {
|
||||
const auth: any = React.useContext(AuthContext);
|
||||
const orgslug = params.orgslug;
|
||||
|
||||
let org = await getOrganizationContextInfo(orgslug, {});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthProvider>
|
||||
<Main>
|
||||
<LeftWrapper>
|
||||
<LeftTopArea>
|
||||
|
||||
<Link href={"/"}><Image alt="Learnhouse logo" width={128} src={LearnHouseWhiteLogo} /></Link>
|
||||
{auth.isAuthenticated && (
|
||||
<Avvvatars value={auth.userInfo.user_object.user_id} style="shape" />
|
||||
)}
|
||||
</LeftTopArea>
|
||||
<LeftMenuWrapper>
|
||||
<MenuTitle>Account</MenuTitle>
|
||||
<ul>
|
||||
<li><Link href="/settings/account/profile">Profile</Link></li>
|
||||
<li><Link href="/settings/account/passwords">Passwords</Link></li>
|
||||
</ul>
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={org.org_id} >
|
||||
<MenuTitle>Organization</MenuTitle>
|
||||
<ul>
|
||||
<li><Link href="/settings/organization/general">General</Link></li>
|
||||
</ul>
|
||||
</AuthenticatedClientElement>
|
||||
</LeftMenuWrapper>
|
||||
</LeftWrapper>
|
||||
<RightWrapper>
|
||||
{children}
|
||||
</RightWrapper>
|
||||
</Main></AuthProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsLayout
|
||||
|
||||
|
||||
const Main = styled('div', {
|
||||
display: 'flex',
|
||||
})
|
||||
|
||||
const LeftWrapper = styled('div', {
|
||||
width: '270px',
|
||||
background: "linear-gradient(348.55deg, #010101 -8.61%, #343434 105.52%);",
|
||||
height: '100vh',
|
||||
padding: '20px',
|
||||
})
|
||||
|
||||
const LeftTopArea = styled('div', {
|
||||
display: 'flex',
|
||||
marginLeft: '20px',
|
||||
|
||||
alignItems: 'center',
|
||||
|
||||
img: {
|
||||
marginRight: '20px',
|
||||
},
|
||||
|
||||
a: {
|
||||
display: 'flex',
|
||||
placeItems: 'center',
|
||||
placeContent: 'center',
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
const LeftMenuWrapper = styled('div', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '20px',
|
||||
|
||||
ul: {
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
li: {
|
||||
marginBottom: '10px',
|
||||
a: {
|
||||
color: '#ffffff8c',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
const MenuTitle = styled('h3', {
|
||||
color: 'white',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '20px',
|
||||
})
|
||||
|
||||
const RightWrapper = styled('div', {
|
||||
flex: 1,
|
||||
padding: '20px',
|
||||
boxSizing: 'border-box',
|
||||
margin: '40px',
|
||||
})
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
import React, { useState } from 'react'
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { updateOrganization, uploadOrganizationLogo } from '@services/settings/org';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import { revalidateTags } from '@services/utils/ts/requests';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
|
||||
interface OrganizationValues {
|
||||
name: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
logo: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
|
||||
function OrganizationClient(props: any) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
// ...
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files.length > 0) {
|
||||
const file = event.target.files[0];
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadLogo = async () => {
|
||||
if (selectedFile) {
|
||||
let org_id = org.org_id;
|
||||
await uploadOrganizationLogo(org_id, selectedFile);
|
||||
setSelectedFile(null); // Reset the selected file
|
||||
await revalidateTags(['organizations'], org.slug);
|
||||
router.refresh();
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const org = props.org;
|
||||
let orgValues: OrganizationValues = {
|
||||
name: org.name,
|
||||
description: org.description,
|
||||
slug: org.slug,
|
||||
logo: org.logo,
|
||||
email: org.email
|
||||
}
|
||||
|
||||
const updateOrg = async (values: OrganizationValues) => {
|
||||
let org_id = org.org_id;
|
||||
await updateOrganization(org_id, values);
|
||||
|
||||
// Mutate the org
|
||||
await revalidateTags(['organizations'], org.slug);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold'>Organization Settings</h1>
|
||||
<br /><br />
|
||||
|
||||
|
||||
<Formik
|
||||
initialValues={orgValues}
|
||||
onSubmit={(values, { setSubmitting }) => {
|
||||
setTimeout(() => {
|
||||
alert(JSON.stringify(values, null, 2));
|
||||
setSubmitting(false);
|
||||
updateOrg(values)
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<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">
|
||||
Logo
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-center w-full ">
|
||||
<input
|
||||
className="w-full px-4 py-2 mr-1 border rounded-lg bg-gray-200 cursor-not-allowed"
|
||||
type="file"
|
||||
name="logo"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadLogo}
|
||||
disabled={isSubmitting || selectedFile === null}
|
||||
className="px-6 py-3 text-white bg-gray-500 rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
>
|
||||
<UploadCloud size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<label className="block mb-2 font-bold" htmlFor="slug">
|
||||
Slug
|
||||
</label>
|
||||
<Field
|
||||
className="w-full px-4 py-2 mb-4 border rounded-lg bg-gray-200 cursor-not-allowed"
|
||||
disabled
|
||||
type="text"
|
||||
name="slug"
|
||||
/>
|
||||
|
||||
<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="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>
|
||||
</Form>
|
||||
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationClient
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { getOrganizationContextInfo } from '@services/organizations/orgs';
|
||||
import { Metadata } from 'next';
|
||||
import OrganizationClient from './organization';
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
return {
|
||||
title: `Settings: General — ${org.name}`,
|
||||
description: org.description,
|
||||
};
|
||||
}
|
||||
|
||||
async function SettingsOrganizationGeneral(params: any) {
|
||||
const orgslug = params.params.orgslug;
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<OrganizationClient org={org} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsOrganizationGeneral
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
|
||||
function SettingsOrganizationRole() {
|
||||
return (
|
||||
<div>SettingsOrganizationRole</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsOrganizationRole
|
||||
28
apps/web/app/orgs/[orgslug]/settings/page.tsx
Normal file
28
apps/web/app/orgs/[orgslug]/settings/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
import { Metadata, ResolvingMetadata } from 'next';
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
|
||||
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
return {
|
||||
title: `Settings — ${org.name}`,
|
||||
description: org.description,
|
||||
};
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
return (
|
||||
<div>Settings</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
34
apps/web/app/orgs/[orgslug]/signup/page.tsx
Normal file
34
apps/web/app/orgs/[orgslug]/signup/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
|
||||
import React from "react";
|
||||
import SignUpClient from "./signup";
|
||||
import { Metadata } from "next";
|
||||
import { getOrganizationContextInfo } from "@services/organizations/orgs";
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string, courseid: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: MetadataProps,
|
||||
): Promise<Metadata> {
|
||||
const orgslug = params.orgslug;
|
||||
// Get Org context information
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
return {
|
||||
title: 'Sign up' + ` — ${org.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
const SignUp = async (params: any) => {
|
||||
const orgslug = params.params.orgslug;
|
||||
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SignUpClient org={org}></SignUpClient>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SignUp;
|
||||
188
apps/web/app/orgs/[orgslug]/signup/signup.tsx
Normal file
188
apps/web/app/orgs/[orgslug]/signup/signup.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import learnhouseIcon from "public/learnhouse_bigicon_1.png";
|
||||
import React from 'react'
|
||||
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input, Textarea } from '@components/StyledElements/Form/Form'
|
||||
import Image from 'next/image';
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { getOrgLogoMediaDirectory } from '@services/media/media';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { signup } from '@services/auth/auth';
|
||||
import { getUriWithOrg } from '@services/config/config';
|
||||
|
||||
|
||||
interface SignUpClientProps {
|
||||
org: any;
|
||||
}
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
|
||||
if (!values.email) {
|
||||
errors.email = 'Required';
|
||||
}
|
||||
else if (
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)
|
||||
) {
|
||||
errors.email = 'Invalid email address';
|
||||
}
|
||||
|
||||
if (!values.password) {
|
||||
errors.password = 'Required';
|
||||
}
|
||||
else if (values.password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (!values.username) {
|
||||
errors.username = 'Required';
|
||||
}
|
||||
|
||||
if (!values.username || values.username.length < 4) {
|
||||
errors.username = 'Username must be at least 4 characters';
|
||||
}
|
||||
|
||||
if (!values.full_name) {
|
||||
errors.full_name = 'Required';
|
||||
}
|
||||
|
||||
if (!values.bio) {
|
||||
errors.bio = 'Required';
|
||||
}
|
||||
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
|
||||
function SignUpClient(props: SignUpClientProps) {
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState('');
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
org_slug: props.org?.slug,
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
bio: '',
|
||||
full_name: '',
|
||||
},
|
||||
validate,
|
||||
onSubmit: async values => {
|
||||
setIsSubmitting(true);
|
||||
let res = await signup(values);
|
||||
let message = await res.json();
|
||||
if (res.status == 200) {
|
||||
router.push(`/`);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
else if (res.status == 401 || res.status == 400 || res.status == 404 || res.status == 409) {
|
||||
setError(message.detail);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
else {
|
||||
setError("Something went wrong");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div><div className='grid grid-flow-col justify-stretch h-screen'>
|
||||
<div className="right-login-part" style={{ background: "linear-gradient(041.61deg, #202020 7.15%, #000000 90.96%)" }} >
|
||||
<div className='login-topbar m-10'>
|
||||
<Link prefetch href={getUriWithOrg(props.org.slug, "/")}>
|
||||
<Image quality={100} width={30} height={30} src={learnhouseIcon} alt="" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ml-10 h-4/6 flex flex-row text-white">
|
||||
<div className="m-auto flex space-x-4 items-center flex-wrap">
|
||||
<div>Join </div>
|
||||
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" >
|
||||
{props.org?.logo ? (
|
||||
<img
|
||||
src={`${getOrgLogoMediaDirectory(props.org.org_id, props.org?.logo)}`}
|
||||
alt="Learnhouse"
|
||||
style={{ width: "auto", height: 70 }}
|
||||
className="rounded-md shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-bold text-xl">{props.org?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="left-login-part bg-white flex flex-row">
|
||||
<div className="login-form m-auto w-72">
|
||||
{error && (
|
||||
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
|
||||
<AlertTriangle size={18} />
|
||||
<div className="font-bold text-sm">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<FormField name="email">
|
||||
<FormLabelAndMessage label='Email' message={formik.errors.email} />
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
{/* for password */}
|
||||
<FormField name="password">
|
||||
<FormLabelAndMessage label='Password' message={formik.errors.password} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
{/* for username */}
|
||||
<FormField name="username">
|
||||
<FormLabelAndMessage label='Username' message={formik.errors.username} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
{/* for full name */}
|
||||
<FormField name="full_name">
|
||||
<FormLabelAndMessage label='Full Name' message={formik.errors.full_name} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input onChange={formik.handleChange} value={formik.values.full_name} type="text" required />
|
||||
</Form.Control>
|
||||
|
||||
</FormField>
|
||||
|
||||
{/* for bio */}
|
||||
<FormField name="bio">
|
||||
<FormLabelAndMessage label='Bio' message={formik.errors.bio} />
|
||||
|
||||
<Form.Control asChild>
|
||||
<Textarea onChange={formik.handleChange} value={formik.values.bio} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<div className="flex py-4">
|
||||
<Form.Submit asChild>
|
||||
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" >
|
||||
{isSubmitting ? "Loading..."
|
||||
: "Create an account"}
|
||||
</button>
|
||||
</Form.Submit>
|
||||
</div>
|
||||
|
||||
</FormLayout>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUpClient
|
||||
25
apps/web/app/orgs/[orgslug]/template.tsx
Normal file
25
apps/web/app/orgs/[orgslug]/template.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const variants = {
|
||||
hidden: { opacity: 0, x: 0, y: 0 },
|
||||
enter: { opacity: 1, x: 0, y: 0 },
|
||||
exit: { opacity: 0, x: 0, y: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.main
|
||||
variants={variants} // Pass the variant object into Framer Motion
|
||||
initial="hidden" // Set the initial state to variants.hidden
|
||||
animate="enter" // Animated state to variants.enter
|
||||
exit="exit" // Exit state (used later) to variants.exit
|
||||
transition={{ type: "linear" }} // Set the transition to linear
|
||||
className=""
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
apps/web/app/page.tsx
Normal file
91
apps/web/app/page.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
import type { NextPage } from "next";
|
||||
import { motion } from "framer-motion";
|
||||
import styled from "styled-components";
|
||||
import learnhouseBigIcon from "public/learnhouse_bigicon.png";
|
||||
import Image from "next/legacy/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<HomePage>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 70,
|
||||
delay: 0.2,
|
||||
}}
|
||||
exit={{ opacity: 1 }}
|
||||
>
|
||||
<Image alt="Learnhouse Icon" height={260} width={260} quality={100} src={learnhouseBigIcon}></Image>
|
||||
</motion.div>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 70,
|
||||
delay: 0.8,
|
||||
}}
|
||||
exit={{ opacity: 1 }}
|
||||
>
|
||||
<div>
|
||||
<Link href={"/organizations"}>
|
||||
<OrgsButton>See Organizations</OrgsButton>
|
||||
</Link>
|
||||
<br />
|
||||
<br />
|
||||
<Link href={"/login"}>
|
||||
<OrgsButton>Login</OrgsButton>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</HomePage>
|
||||
);
|
||||
};
|
||||
|
||||
const OrgsButton = styled.button`
|
||||
background: #151515;
|
||||
border: 1px solid #e5e5e50a;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
padding: 10px 20px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin: 0 10px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
font-family: "DM Sans";
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
-webkit-transition: all 0.2s ease-in-out;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
background: #191919;
|
||||
}
|
||||
`;
|
||||
|
||||
const HomePage = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(131.61deg, #202020 7.15%, #000000 90.96%);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
img {
|
||||
width: 60px;
|
||||
}
|
||||
`;
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue