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
|
|
@ -0,0 +1,19 @@
|
|||
import { getBackendUrl } from "@services/config/config";
|
||||
import { getActivityMediaDirectory } from "@services/media/media";
|
||||
import React from "react";
|
||||
|
||||
function DocumentPdfActivity({ activity, course }: { activity: any; course: any }) {
|
||||
|
||||
return (
|
||||
<div className="m-8 bg-zinc-900 rounded-md mt-14">
|
||||
<iframe
|
||||
className="rounded-lg w-full h-[900px]"
|
||||
src={getActivityMediaDirectory(activity.org_id, activity.course_id, activity.activity_id, activity.content.documentpdf.filename, 'documentpdf')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentPdfActivity;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import styled from "styled-components"
|
||||
import Youtube from "@tiptap/extension-youtube";
|
||||
// Custom Extensions
|
||||
import InfoCallout from "@components/Objects/Editor/Extensions/Callout/Info/InfoCallout";
|
||||
import WarningCallout from "@components/Objects/Editor/Extensions/Callout/Warning/WarningCallout";
|
||||
import ImageBlock from "@components/Objects/Editor/Extensions/Image/ImageBlock";
|
||||
import VideoBlock from "@components/Objects/Editor/Extensions/Video/VideoBlock";
|
||||
import MathEquationBlock from "@components/Objects/Editor/Extensions/MathEquation/MathEquationBlock";
|
||||
import PDFBlock from "@components/Objects/Editor/Extensions/PDF/PDFBlock";
|
||||
import { OrderedList } from "@tiptap/extension-ordered-list";
|
||||
import QuizBlock from "@components/Objects/Editor/Extensions/Quiz/QuizBlock";
|
||||
|
||||
// Lowlight
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
const lowlight = createLowlight(common)
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import js from 'highlight.js/lib/languages/javascript'
|
||||
import ts from 'highlight.js/lib/languages/typescript'
|
||||
import html from 'highlight.js/lib/languages/xml'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
|
||||
|
||||
interface Editor {
|
||||
content: string;
|
||||
activity: any;
|
||||
//course: any;
|
||||
}
|
||||
|
||||
function Canva(props: Editor) {
|
||||
const isEditable = false;
|
||||
|
||||
// Code Block Languages for Lowlight
|
||||
lowlight.register('html', html)
|
||||
lowlight.register('css', css)
|
||||
lowlight.register('js', js)
|
||||
lowlight.register('ts', ts)
|
||||
lowlight.register('python', python)
|
||||
lowlight.register('java', java)
|
||||
|
||||
|
||||
const editor: any = useEditor({
|
||||
editable: isEditable,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
// Custom Extensions
|
||||
InfoCallout.configure({
|
||||
editable: isEditable,
|
||||
}),
|
||||
WarningCallout.configure({
|
||||
editable: isEditable,
|
||||
}),
|
||||
ImageBlock.configure({
|
||||
editable: isEditable,
|
||||
activity: props.activity,
|
||||
}),
|
||||
VideoBlock.configure({
|
||||
editable: true,
|
||||
activity: props.activity,
|
||||
}),
|
||||
MathEquationBlock.configure({
|
||||
editable: false,
|
||||
activity: props.activity,
|
||||
}),
|
||||
PDFBlock.configure({
|
||||
editable: true,
|
||||
activity: props.activity,
|
||||
}),
|
||||
QuizBlock.configure({
|
||||
editable: isEditable,
|
||||
activity: props.activity,
|
||||
}),
|
||||
Youtube.configure({
|
||||
controls: true,
|
||||
modestBranding: true,
|
||||
}),
|
||||
OrderedList.configure(),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
|
||||
],
|
||||
|
||||
content: props.content,
|
||||
});
|
||||
|
||||
return (
|
||||
<CanvaWrapper>
|
||||
<EditorContent editor={editor} />
|
||||
</CanvaWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const CanvaWrapper = styled.div`
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
// disable chrome outline
|
||||
|
||||
.ProseMirror {
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding: 0 1rem;
|
||||
padding-left: 20px;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
outline-style: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// Code Block
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
font-family: "JetBrainsMono", monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #f98181;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params {
|
||||
color: #fbbc88;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #b9f18d;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #faf594;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #70cff8;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
`;
|
||||
|
||||
export default Canva;
|
||||
73
apps/web/components/Objects/Activities/Video/Video.tsx
Normal file
73
apps/web/components/Objects/Activities/Video/Video.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { getBackendUrl } from "@services/config/config";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import YouTube from 'react-youtube';
|
||||
import { getActivityMediaDirectory } from "@services/media/media";
|
||||
|
||||
function VideoActivity({ activity, course }: { activity: any; course: any }) {
|
||||
const [videoId, setVideoId] = React.useState('');
|
||||
const [videoType, setVideoType] = React.useState('');
|
||||
|
||||
function getYouTubeEmbed(url: any) {
|
||||
// Extract video ID from the YouTube URL
|
||||
var videoId = url.match(/(?:\?v=|\/embed\/|\/\d\/|\/vi\/|\/v\/|https?:\/\/(?:www\.)?youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))([^#\&\?\/]+)/)[1];
|
||||
|
||||
// Create the embed object
|
||||
var embedObject = {
|
||||
videoId: videoId,
|
||||
width: 560,
|
||||
height: 315
|
||||
};
|
||||
|
||||
return embedObject;
|
||||
}
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activity.content.video) {
|
||||
setVideoType('video');
|
||||
}
|
||||
if (activity.content.external_video) {
|
||||
setVideoType('external_video');
|
||||
setVideoId(getYouTubeEmbed(activity.content.external_video.uri).videoId);
|
||||
}
|
||||
}, [activity]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{videoType === 'video' && (
|
||||
<div className="m-8 bg-zinc-900 rounded-md mt-14">
|
||||
<video className="rounded-lg w-full h-[500px]" controls
|
||||
src={getActivityMediaDirectory(activity.org_id, activity.course_id, activity.activity_id, activity.content.video.filename, 'video')}
|
||||
></video>
|
||||
|
||||
</div>
|
||||
)}
|
||||
{videoType === 'external_video' && (
|
||||
<div>
|
||||
<YouTube
|
||||
className="rounded-md overflow-hidden m-8 bg-zinc-900 mt-14"
|
||||
opts={
|
||||
{
|
||||
width: '1300',
|
||||
height: '500',
|
||||
playerVars: {
|
||||
autoplay: 0,
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
videoId={videoId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export default VideoActivity;
|
||||
|
||||
|
||||
448
apps/web/components/Objects/Editor/Editor.tsx
Normal file
448
apps/web/components/Objects/Editor/Editor.tsx
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
'use client';
|
||||
import React from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { AuthContext } from "../../Security/AuthProvider";
|
||||
import learnhouseIcon from "public/learnhouse_icon.png";
|
||||
import { ToolbarButtons } from "./Toolbar/ToolbarButtons";
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import styled from "styled-components";
|
||||
import { DividerVerticalIcon, SlashIcon } from "@radix-ui/react-icons";
|
||||
import Avvvatars from "avvvatars-react";
|
||||
// extensions
|
||||
import InfoCallout from "./Extensions/Callout/Info/InfoCallout";
|
||||
import WarningCallout from "./Extensions/Callout/Warning/WarningCallout";
|
||||
import ImageBlock from "./Extensions/Image/ImageBlock";
|
||||
import Youtube from "@tiptap/extension-youtube";
|
||||
import VideoBlock from "./Extensions/Video/VideoBlock";
|
||||
import { Eye } from "lucide-react";
|
||||
import MathEquationBlock from "./Extensions/MathEquation/MathEquationBlock";
|
||||
import PDFBlock from "./Extensions/PDF/PDFBlock";
|
||||
import QuizBlock from "./Extensions/Quiz/QuizBlock";
|
||||
import ToolTip from "@components/StyledElements/Tooltip/Tooltip";
|
||||
import Link from "next/link";
|
||||
import { getCourseThumbnailMediaDirectory } from "@services/media/media";
|
||||
import { OrderedList } from "@tiptap/extension-ordered-list";
|
||||
|
||||
|
||||
// Lowlight
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
const lowlight = createLowlight(common)
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import js from 'highlight.js/lib/languages/javascript'
|
||||
import ts from 'highlight.js/lib/languages/typescript'
|
||||
import html from 'highlight.js/lib/languages/xml'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
|
||||
|
||||
interface Editor {
|
||||
content: string;
|
||||
ydoc: any;
|
||||
provider: any;
|
||||
activity: any;
|
||||
orgslug: string
|
||||
course: any;
|
||||
setContent: (content: string) => void;
|
||||
}
|
||||
|
||||
function Editor(props: Editor) {
|
||||
const auth: any = React.useContext(AuthContext);
|
||||
// remove course_ from course_id
|
||||
const course_id = props.course.course.course_id.substring(7);
|
||||
|
||||
// remove activity_ from activity_id
|
||||
const activity_id = props.activity.activity_id.substring(9);
|
||||
|
||||
// Code Block Languages for Lowlight
|
||||
lowlight.register('html', html)
|
||||
lowlight.register('css', css)
|
||||
lowlight.register('js', js)
|
||||
lowlight.register('ts', ts)
|
||||
lowlight.register('python', python)
|
||||
lowlight.register('java', java)
|
||||
|
||||
const editor: any = useEditor({
|
||||
editable: true,
|
||||
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// The Collaboration extension comes with its own history handling
|
||||
// history: false,
|
||||
}),
|
||||
InfoCallout.configure({
|
||||
editable: true,
|
||||
}),
|
||||
WarningCallout.configure({
|
||||
editable: true,
|
||||
}),
|
||||
ImageBlock.configure({
|
||||
editable: true,
|
||||
activity: props.activity,
|
||||
}),
|
||||
VideoBlock.configure({
|
||||
editable: true,
|
||||
activity: props.activity,
|
||||
}),
|
||||
MathEquationBlock.configure({
|
||||
editable: true,
|
||||
activity: props.activity,
|
||||
}),
|
||||
PDFBlock.configure({
|
||||
editable: true,
|
||||
activity: props.activity,
|
||||
}),
|
||||
QuizBlock.configure({
|
||||
editable: true,
|
||||
activity: props.activity,
|
||||
}),
|
||||
Youtube.configure({
|
||||
controls: true,
|
||||
modestBranding: true,
|
||||
}),
|
||||
OrderedList.configure(),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
|
||||
|
||||
// Register the document with Tiptap
|
||||
// Collaboration.configure({
|
||||
// document: props.ydoc,
|
||||
// }),
|
||||
// Register the collaboration cursor extension
|
||||
// CollaborationCursor.configure({
|
||||
// provider: props.provider,
|
||||
// user: {
|
||||
// name: auth.userInfo.username,
|
||||
// color: "#f783ac",
|
||||
// },
|
||||
// }),
|
||||
],
|
||||
|
||||
content: props.content,
|
||||
});
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
key="modal"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 360,
|
||||
damping: 70,
|
||||
delay: 0.02,
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<EditorTop className="fixed bg-white bg-opacity-95 backdrop-blur backdrop-brightness-125">
|
||||
<EditorDocSection>
|
||||
<EditorInfoWrapper>
|
||||
<Link href="/">
|
||||
<EditorInfoLearnHouseLogo width={25} height={25} src={learnhouseIcon} alt="" />
|
||||
</Link>
|
||||
<Link target="_blank" href={`/course/${course_id}/edit`}>
|
||||
<EditorInfoThumbnail src={`${getCourseThumbnailMediaDirectory(props.course.course.org_id, props.course.course.course_id, props.course.course.thumbnail)}`} alt=""></EditorInfoThumbnail>
|
||||
</Link>
|
||||
<EditorInfoDocName>
|
||||
{" "}
|
||||
<b>{props.course.course.name}</b> <SlashIcon /> {props.activity.name}{" "}
|
||||
</EditorInfoDocName>
|
||||
|
||||
</EditorInfoWrapper>
|
||||
<EditorButtonsWrapper>
|
||||
<ToolbarButtons editor={editor} />
|
||||
</EditorButtonsWrapper>
|
||||
</EditorDocSection>
|
||||
<EditorUsersSection>
|
||||
<EditorUserProfileWrapper>
|
||||
{!auth.isAuthenticated && <span>Loading</span>}
|
||||
{auth.isAuthenticated && <Avvvatars value={auth.userInfo.user_object.user_id} style="shape" />}
|
||||
</EditorUserProfileWrapper>
|
||||
<DividerVerticalIcon style={{ marginTop: "auto", marginBottom: "auto", color: "grey", opacity: '0.5' }} />
|
||||
<EditorLeftOptionsSection className="space-x-2 pl-2 pr-3">
|
||||
<div className="bg-sky-600 hover:bg-sky-700 transition-all ease-linear px-3 py-2 font-black text-sm shadow text-teal-100 rounded-lg hover:cursor-pointer" onClick={() => props.setContent(editor.getJSON())}> Save </div>
|
||||
<ToolTip content="Preview">
|
||||
<Link target="_blank" href={`/course/${course_id}/activity/${activity_id}`}>
|
||||
<div className="flex bg-neutral-600 hover:bg-neutral-700 transition-all ease-linear h-9 px-3 py-2 font-black justify-center items-center text-sm shadow text-neutral-100 rounded-lg hover:cursor-pointer">
|
||||
<Eye className="mx-auto items-center" size={15} />
|
||||
</div>
|
||||
</Link>
|
||||
</ToolTip>
|
||||
</EditorLeftOptionsSection>
|
||||
</EditorUsersSection>
|
||||
</EditorTop>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.99 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 360,
|
||||
damping: 70,
|
||||
delay: 0.5,
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<EditorContentWrapper>
|
||||
<EditorContent editor={editor} />
|
||||
</EditorContentWrapper>
|
||||
</motion.div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
padding-top: 30px;
|
||||
|
||||
// dots background
|
||||
background-image: radial-gradient(#4744446b 1px, transparent 1px), radial-gradient(#4744446b 1px, transparent 1px);
|
||||
background-position: 0 0, 25px 25px;
|
||||
background-size: 50px 50px;
|
||||
background-attachment: fixed;
|
||||
background-repeat: repeat;
|
||||
`;
|
||||
|
||||
const EditorTop = styled.div`
|
||||
border-radius: 15px;
|
||||
margin: 40px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.03);
|
||||
position: fixed;
|
||||
z-index: 303;
|
||||
width: -webkit-fill-available;
|
||||
`;
|
||||
|
||||
// Inside EditorTop
|
||||
const EditorDocSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
const EditorUsersSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const EditorLeftOptionsSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
|
||||
// Inside EditorDocSection
|
||||
const EditorInfoWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
const EditorButtonsWrapper = styled.div``;
|
||||
|
||||
// Inside EditorUsersSection
|
||||
const EditorUserProfileWrapper = styled.div`
|
||||
padding-right: 8px;
|
||||
svg {
|
||||
border-radius: 7px;
|
||||
}
|
||||
`;
|
||||
|
||||
// Inside EditorInfoWrapper
|
||||
//..todo
|
||||
const EditorInfoLearnHouseLogo = styled(Image)`
|
||||
border-radius: 6px;
|
||||
margin-right: 0px;
|
||||
`;
|
||||
const EditorInfoDocName = styled.div`
|
||||
font-size: 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-left: 10px;
|
||||
color: #494949;
|
||||
|
||||
svg {
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
padding: 3px;
|
||||
color: #353535;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
const EditorInfoThumbnail = styled.img`
|
||||
height: 25px;
|
||||
width: 56px;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border-radius: 7px;
|
||||
margin-left: 5px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export const EditorContentWrapper = styled.div`
|
||||
margin: 40px;
|
||||
margin-top: 90px;
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
z-index: 300;
|
||||
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.03);
|
||||
|
||||
// disable chrome outline
|
||||
|
||||
.ProseMirror {
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 20px;
|
||||
padding-top: 20px;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
outline-style: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// Code Block
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
font-family: "JetBrainsMono", monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #f98181;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params {
|
||||
color: #fbbc88;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #b9f18d;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #faf594;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #70cff8;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
iframe {
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
min-width: 200px;
|
||||
width: 100%;
|
||||
height: 440px;
|
||||
min-height: 200px;
|
||||
display: block;
|
||||
outline: 0px solid transparent;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding: 0 1rem;
|
||||
padding-left: 20px;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
`;
|
||||
|
||||
export default Editor;
|
||||
58
apps/web/components/Objects/Editor/EditorWrapper.tsx
Normal file
58
apps/web/components/Objects/Editor/EditorWrapper.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
'use client';
|
||||
import { default as React, } from "react";
|
||||
import * as Y from "yjs";
|
||||
import Editor from "./Editor";
|
||||
import { updateActivity } from "@services/courses/activities";
|
||||
import { toast } from "react-hot-toast";
|
||||
import Toast from "@components/StyledElements/Toast/Toast";
|
||||
|
||||
interface EditorWrapperProps {
|
||||
content: string;
|
||||
activity: any;
|
||||
course: any
|
||||
orgslug: string;
|
||||
}
|
||||
|
||||
function EditorWrapper(props: EditorWrapperProps): JSX.Element {
|
||||
// A new Y document
|
||||
const ydoc = new Y.Doc();
|
||||
const [providerState, setProviderState] = React.useState<any>({});
|
||||
const [ydocState, setYdocState] = React.useState<any>({});
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
function createRTCProvider() {
|
||||
// const provider = new WebrtcProvider(props.activity.activity_id, ydoc);
|
||||
// setYdocState(ydoc);
|
||||
// setProviderState(provider);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function setContent(content: any) {
|
||||
let activity = props.activity;
|
||||
activity.content = content;
|
||||
|
||||
toast.promise(
|
||||
updateActivity(activity, activity.activity_id),
|
||||
{
|
||||
loading: 'Saving...',
|
||||
success: <b>Activity saved!</b>,
|
||||
error: <b>Could not save.</b>,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
createRTCProvider();
|
||||
return <div>Loading...</div>;
|
||||
} else {
|
||||
return <>
|
||||
<Toast></Toast>
|
||||
<Editor orgslug={props.orgslug} course={props.course} activity={props.activity} content={props.content} setContent={setContent} provider={providerState} ydoc={ydocState}></Editor>;
|
||||
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
export default EditorWrapper;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import InfoCalloutComponent from "./InfoCalloutComponent";
|
||||
|
||||
export default Node.create({
|
||||
name: "calloutInfo",
|
||||
group: "block",
|
||||
draggable: true,
|
||||
content: "text*",
|
||||
|
||||
// TODO : multi line support
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "callout-info",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["callout-info", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(InfoCalloutComponent);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
function InfoCalloutComponent(props: any) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<InfoCalloutWrapper className="flex space-x-2 items-center bg-blue-200 rounded-lg text-blue-900 px-3 shadow-inner" contentEditable={props.extension.options.editable}>
|
||||
<AlertCircle /> <NodeViewContent contentEditable={props.extension.options.editable} className="content" />
|
||||
</InfoCalloutWrapper>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const InfoCalloutWrapper = styled.div`
|
||||
svg{
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 5px;
|
||||
padding: 0.5rem;
|
||||
border: ${(props) => (props.contentEditable ? "2px dashed #1f3a8a12" : "none")};
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
|
||||
export default InfoCalloutComponent;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import WarningCalloutComponent from "./WarningCalloutComponent";
|
||||
|
||||
export default Node.create({
|
||||
name: "calloutWarning",
|
||||
group: "block",
|
||||
draggable: true,
|
||||
content: "text*",
|
||||
marks: "",
|
||||
defining: true,
|
||||
|
||||
// TODO : multi line support
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "callout-warning",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["callout-info", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(WarningCalloutComponent);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
function WarningCalloutComponent(props: any) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<CalloutWrapper className="flex space-x-2 items-center bg-yellow-200 rounded-lg text-yellow-900 px-3 shadow-inner" contentEditable={props.extension.options.editable}>
|
||||
<AlertTriangle /> <NodeViewContent contentEditable={props.extension.options.editable} className="content" />
|
||||
</CalloutWrapper>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const CalloutWrapper = styled.div`
|
||||
|
||||
|
||||
svg {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 5px;
|
||||
padding: 0.5rem;
|
||||
border: ${(props) => (props.contentEditable ? "2px dashed #713f1117" : "none")};
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const DragHandle = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1rem;
|
||||
height: 100%;
|
||||
cursor: move;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export default WarningCalloutComponent;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import ImageBlockComponent from "./ImageBlockComponent";
|
||||
|
||||
export default Node.create({
|
||||
name: "blockImage",
|
||||
group: "block",
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
blockObject: {
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
width: 300,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "block-image",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["block-image", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageBlockComponent);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Resizable } from 're-resizable';
|
||||
import { AlertTriangle, Image, Loader } from "lucide-react";
|
||||
import { uploadNewImageFile } from "../../../../../services/blocks/Image/images";
|
||||
import { UploadIcon } from "@radix-ui/react-icons";
|
||||
import { getActivityBlockMediaDirectory } from "@services/media/media";
|
||||
|
||||
function ImageBlockComponent(props: any) {
|
||||
const [image, setImage] = React.useState(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [blockObject, setblockObject] = React.useState(props.node.attrs.blockObject);
|
||||
const [imageSize, setImageSize] = React.useState({ width: props.node.attrs.size ? props.node.attrs.size.width : 300 });
|
||||
const fileId = blockObject ? `${blockObject.block_data.file_id}.${blockObject.block_data.file_format}` : null;
|
||||
|
||||
const handleImageChange = (event: React.ChangeEvent<any>) => {
|
||||
setImage(event.target.files[0]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
let object = await uploadNewImageFile(image, props.extension.options.activity.activity_id);
|
||||
setIsLoading(false);
|
||||
setblockObject(object);
|
||||
props.updateAttributes({
|
||||
blockObject: object,
|
||||
size: imageSize,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="block-image">
|
||||
{!blockObject && props.extension.options.editable && (
|
||||
<BlockImageWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={props.extension.options.editable}>
|
||||
{isLoading ? (
|
||||
<Loader className="animate-spin animate-pulse text-gray-200" size={50} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Image className="text-gray-200" size={50} />
|
||||
</div>
|
||||
<input className="p-3 rounded-lg file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 hover:file:cursor-pointer file:bg-gray-200 cursor-pointer file:text-gray-500" onChange={handleImageChange} type="file" name="" id="" />
|
||||
<button className='p-2 px-3 bg-gray-200 rounded-lg text-gray-500 hover:bg-gray-300 transition space-x-2 items-center flex' onClick={handleSubmit}><UploadIcon></UploadIcon><p>Submit</p></button>
|
||||
</>
|
||||
)}
|
||||
</BlockImageWrapper>
|
||||
)}
|
||||
{blockObject && (
|
||||
<Resizable defaultSize={{ width: imageSize.width, height: "100%" }}
|
||||
handleStyles={{
|
||||
right: { position: 'unset', width: 7, height: 30, borderRadius: 20, cursor: 'col-resize', backgroundColor: 'black', opacity: '0.3', margin: 'auto', marginLeft:5 },
|
||||
|
||||
}}
|
||||
style={{ margin: "auto", display: "flex", justifyContent: "center", alignItems: "center", height: "100%" }}
|
||||
maxWidth={1000}
|
||||
minWidth={200}
|
||||
onResizeStop={(e, direction, ref, d) => {
|
||||
props.updateAttributes({
|
||||
size: {
|
||||
width: imageSize.width + d.width,
|
||||
}
|
||||
});
|
||||
setImageSize({
|
||||
width: imageSize.width + d.width,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
<img
|
||||
src={`${getActivityBlockMediaDirectory(props.extension.options.activity.org_id,
|
||||
props.extension.options.activity.course_id,
|
||||
props.extension.options.activity.activity_id,
|
||||
blockObject.block_id,
|
||||
blockObject ? fileId : ' ', 'imageBlock')}`}
|
||||
alt=""
|
||||
className="rounded-lg shadow "
|
||||
/>
|
||||
|
||||
|
||||
</Resizable>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div>
|
||||
<AlertTriangle color="#e1e0e0" size={50} />
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageBlockComponent;
|
||||
|
||||
const BlockImageWrapper = styled.div`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
|
||||
`;
|
||||
|
||||
const BlockImage = styled.div`
|
||||
display: flex;
|
||||
|
||||
|
||||
// center
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
|
||||
|
||||
|
||||
`;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import MathEquationBlockComponent from "./MathEquationBlockComponent";
|
||||
|
||||
export default Node.create({
|
||||
name: "blockMathEquation",
|
||||
group: "block",
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
math_equation: {
|
||||
default: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "block-math-equation",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["block-math-equation", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MathEquationBlockComponent);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { InlineMath, BlockMath } from "react-katex";
|
||||
import { Edit, Save } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
function MathEquationBlockComponent(props: any) {
|
||||
const [equation, setEquation] = React.useState(props.node.attrs.math_equation);
|
||||
const [isEditing, setIsEditing] = React.useState(true);
|
||||
const isEditable = props.extension.options.editable;
|
||||
|
||||
const handleEquationChange = (event: React.ChangeEvent<any>) => {
|
||||
setEquation(event.target.value);
|
||||
props.updateAttributes({
|
||||
math_equation: equation,
|
||||
});
|
||||
};
|
||||
|
||||
const saveEquation = () => {
|
||||
props.updateAttributes({
|
||||
math_equation: equation,
|
||||
});
|
||||
//setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="block-math-equation">
|
||||
<MathEqWrapper className="flex flex-col space-y-2 bg-gray-50 shadow-inner rounded-lg py-7 px-5">
|
||||
<BlockMath>{equation}</BlockMath>
|
||||
{isEditing && isEditable && (
|
||||
<>
|
||||
<EditBar>
|
||||
<input value={equation} onChange={handleEquationChange} placeholder="Insert a Math Equation (LaTeX) " type="text" />
|
||||
<button className="opacity-1" onClick={() => saveEquation()}>
|
||||
<Save size={15}></Save>
|
||||
</button>
|
||||
</EditBar>
|
||||
<span className="pt-2 text-zinc-500 text-sm">Please refer to this <Link className="text-zinc-900 after:content-['↗']" href="https://katex.org/docs/supported.html" target="_blank"> guide</Link> for supported TeX functions </span>
|
||||
</>
|
||||
|
||||
)}
|
||||
</MathEqWrapper>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MathEquationBlockComponent;
|
||||
|
||||
const MathEqWrapper = styled.div`
|
||||
`;
|
||||
|
||||
const EditBar = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
color: #5252528d;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 50px;
|
||||
border: solid 1px #52525224;
|
||||
|
||||
button {
|
||||
margin-left: 10px;
|
||||
margin-right: 7px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 14px;
|
||||
color: #494949;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 14px;
|
||||
color: #494949;
|
||||
width: 100%;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
padding-left: 10px;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #49494936;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import PDFBlockComponent from "./PDFBlockComponent";
|
||||
|
||||
export default Node.create({
|
||||
name: "blockPDF",
|
||||
group: "block",
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
blockObject: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "block-pdf",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["block-pdf", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(PDFBlockComponent);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { AlertCircle, AlertTriangle, FileText, Image, ImagePlus, Info, Loader } from "lucide-react";
|
||||
import { getPDFFile, uploadNewPDFFile } from "../../../../../services/blocks/Pdf/pdf";
|
||||
import { getBackendUrl } from "../../../../../services/config/config";
|
||||
import { UploadIcon } from "@radix-ui/react-icons";
|
||||
import { getActivityBlockMediaDirectory } from "@services/media/media";
|
||||
|
||||
function PDFBlockComponent(props: any) {
|
||||
const [pdf, setPDF] = React.useState(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [blockObject, setblockObject] = React.useState(props.node.attrs.blockObject);
|
||||
const fileId = blockObject ? `${blockObject.block_data.file_id}.${blockObject.block_data.file_format}` : null;
|
||||
|
||||
const handlePDFChange = (event: React.ChangeEvent<any>) => {
|
||||
setPDF(event.target.files[0]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
let object = await uploadNewPDFFile(pdf, props.extension.options.activity.activity_id);
|
||||
setIsLoading(false);
|
||||
setblockObject(object);
|
||||
props.updateAttributes({
|
||||
blockObject: object,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="block-pdf">
|
||||
{!blockObject && (
|
||||
<BlockPDFWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={props.extension.options.editable}>
|
||||
{isLoading ? (
|
||||
<Loader className="animate-spin animate-pulse text-gray-200" size={50} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FileText className="text-gray-200" size={50} />
|
||||
</div>
|
||||
<input className="p-3 rounded-lg file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 hover:file:cursor-pointer file:bg-gray-200 cursor-pointer file:text-gray-500" onChange={handlePDFChange} type="file" name="" id="" />
|
||||
<button className='p-2 px-3 bg-gray-200 rounded-lg text-gray-500 hover:bg-gray-300 transition space-x-2 items-center flex' onClick={handleSubmit}><UploadIcon></UploadIcon><p>Submit</p></button>
|
||||
</>
|
||||
)}
|
||||
</BlockPDFWrapper>
|
||||
)}
|
||||
{blockObject && (
|
||||
<BlockPDF>
|
||||
<iframe
|
||||
className="shadow rounded-lg h-96 w-full object-scale-down bg-black"
|
||||
src={`${getActivityBlockMediaDirectory(props.extension.options.activity.org_id,
|
||||
props.extension.options.activity.course_id,
|
||||
props.extension.options.activity.activity_id,
|
||||
blockObject.block_id,
|
||||
blockObject ? fileId : ' ', 'pdfBlock')}`}
|
||||
/>
|
||||
</BlockPDF>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div>
|
||||
<AlertTriangle color="#e1e0e0" size={50} />
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PDFBlockComponent;
|
||||
|
||||
const BlockPDFWrapper = styled.div`
|
||||
|
||||
// center
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const BlockPDF = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
img {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
height: 300px;
|
||||
// cover
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
const PDFNotFound = styled.div``;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import QuizBlockComponent from "./QuizBlockComponent";
|
||||
|
||||
export default Node.create({
|
||||
name: "blockQuiz",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
quizId: {
|
||||
value: null,
|
||||
},
|
||||
questions: {
|
||||
default: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "block-quiz",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["block-quiz", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(QuizBlockComponent);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { twJoin, twMerge } from 'tailwind-merge'
|
||||
import React from "react";
|
||||
import { BadgeHelp, Check, Info, Minus, MoreVertical, Plus, RefreshCcw, X } from "lucide-react";
|
||||
import ReactConfetti from "react-confetti";
|
||||
|
||||
interface Answer {
|
||||
answer_id: string;
|
||||
answer: string;
|
||||
correct: boolean;
|
||||
}
|
||||
interface Question {
|
||||
question_id: string;
|
||||
question: string;
|
||||
type: "multiple_choice" | 'custom_answer'
|
||||
answers: Answer[];
|
||||
}
|
||||
|
||||
function QuizBlockComponent(props: any) {
|
||||
const [questions, setQuestions] = React.useState(props.node.attrs.questions) as [Question[], any];
|
||||
const [userAnswers, setUserAnswers] = React.useState([]) as [any[], any];
|
||||
const [submitted, setSubmitted] = React.useState(false) as [boolean, any];
|
||||
const [submissionMessage, setSubmissionMessage] = React.useState("") as [string, any];
|
||||
const isEditable = props.extension.options.editable;
|
||||
|
||||
const handleAnswerClick = (question_id: string, answer_id: string) => {
|
||||
// if the quiz is submitted, do nothing
|
||||
if (submitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userAnswer = {
|
||||
question_id: question_id,
|
||||
answer_id: answer_id
|
||||
}
|
||||
const newAnswers = [...userAnswers, userAnswer];
|
||||
|
||||
// only accept one answer per question
|
||||
const filteredAnswers = newAnswers.filter((answer: any) => answer.question_id !== question_id);
|
||||
|
||||
setUserAnswers([...filteredAnswers, userAnswer]);
|
||||
|
||||
}
|
||||
|
||||
const refreshUserSubmission = () => {
|
||||
setUserAnswers([]);
|
||||
setSubmitted(false);
|
||||
}
|
||||
|
||||
const handleUserSubmission = () => {
|
||||
|
||||
if (userAnswers.length === 0) {
|
||||
setSubmissionMessage("Please answer at least one question!");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
|
||||
// check if all submitted answers are correct
|
||||
const correctAnswers = questions.map((question: Question) => {
|
||||
const correctAnswer: any = question.answers.find((answer: Answer) => answer.correct);
|
||||
const userAnswer = userAnswers.find((userAnswer: any) => userAnswer.question_id === question.question_id);
|
||||
if (correctAnswer.answer_id === userAnswer.answer_id) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// check if all answers are correct
|
||||
const allCorrect = correctAnswers.every((answer: boolean) => answer === true);
|
||||
|
||||
if (allCorrect) {
|
||||
setSubmissionMessage("All answers are correct!");
|
||||
console.log("All answers are correct!");
|
||||
}
|
||||
else {
|
||||
setSubmissionMessage("Some answers are incorrect!");
|
||||
console.log("Some answers are incorrect!");
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
const getAnswerID = (answerIndex: number, questionId : string) => {
|
||||
const alphabet = Array.from({ length: 26 }, (_, i) => String.fromCharCode('A'.charCodeAt(0) + i));
|
||||
let alphabetID = alphabet[answerIndex];
|
||||
|
||||
// Get question index
|
||||
const questionIndex = questions.findIndex((question: Question) => question.question_id === questionId);
|
||||
let questionID = questionIndex + 1;
|
||||
|
||||
return `${alphabetID}`;
|
||||
}
|
||||
|
||||
const saveQuestions = (questions: any) => {
|
||||
props.updateAttributes({
|
||||
questions: questions,
|
||||
});
|
||||
setQuestions(questions);
|
||||
|
||||
};
|
||||
const addSampleQuestion = () => {
|
||||
const newQuestion = {
|
||||
question_id: uuidv4(),
|
||||
question: "",
|
||||
type: "multiple_choice",
|
||||
answers: [
|
||||
{
|
||||
answer_id: uuidv4(),
|
||||
answer: "",
|
||||
correct: false
|
||||
},
|
||||
]
|
||||
}
|
||||
setQuestions([...questions, newQuestion]);
|
||||
}
|
||||
|
||||
const addAnswer = (question_id: string) => {
|
||||
const newAnswer = {
|
||||
answer_id: uuidv4(),
|
||||
answer: "",
|
||||
correct: false
|
||||
}
|
||||
|
||||
// check if there is already more thqn 5 answers
|
||||
const question: any = questions.find((question: Question) => question.question_id === question_id);
|
||||
if (question.answers.length >= 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const newQuestions = questions.map((question: Question) => {
|
||||
if (question.question_id === question_id) {
|
||||
question.answers.push(newAnswer);
|
||||
}
|
||||
return question;
|
||||
});
|
||||
|
||||
saveQuestions(newQuestions);
|
||||
}
|
||||
|
||||
const changeAnswerValue = (question_id: string, answer_id: string, value: string) => {
|
||||
const newQuestions = questions.map((question: Question) => {
|
||||
if (question.question_id === question_id) {
|
||||
question.answers.map((answer: Answer) => {
|
||||
if (answer.answer_id === answer_id) {
|
||||
answer.answer = value;
|
||||
}
|
||||
return answer;
|
||||
});
|
||||
}
|
||||
return question;
|
||||
});
|
||||
saveQuestions(newQuestions);
|
||||
}
|
||||
|
||||
const changeQuestionValue = (question_id: string, value: string) => {
|
||||
const newQuestions = questions.map((question: Question) => {
|
||||
if (question.question_id === question_id) {
|
||||
question.question = value;
|
||||
}
|
||||
return question;
|
||||
});
|
||||
saveQuestions(newQuestions);
|
||||
}
|
||||
|
||||
const deleteQuestion = (question_id: string) => {
|
||||
const newQuestions = questions.filter((question: Question) => question.question_id !== question_id);
|
||||
saveQuestions(newQuestions);
|
||||
}
|
||||
|
||||
const deleteAnswer = (question_id: string, answer_id: string) => {
|
||||
const newQuestions = questions.map((question: Question) => {
|
||||
if (question.question_id === question_id) {
|
||||
question.answers = question.answers.filter((answer: Answer) => answer.answer_id !== answer_id);
|
||||
}
|
||||
return question;
|
||||
});
|
||||
saveQuestions(newQuestions);
|
||||
}
|
||||
|
||||
const markAnswerCorrect = (question_id: string, answer_id: string) => {
|
||||
const newQuestions = questions.map((question: Question) => {
|
||||
if (question.question_id === question_id) {
|
||||
question.answers.map((answer: Answer) => {
|
||||
if (answer.answer_id === answer_id) {
|
||||
answer.correct = true;
|
||||
} else {
|
||||
answer.correct = false;
|
||||
}
|
||||
|
||||
return answer;
|
||||
});
|
||||
}
|
||||
return question;
|
||||
});
|
||||
saveQuestions(newQuestions);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="block-quiz">
|
||||
|
||||
<div
|
||||
//style={{ background: "radial-gradient(152.15% 150.08% at 56.45% -6.67%, rgba(180, 255, 250, 0.10) 5.53%, rgba(202, 201, 255, 0.10) 66.76%)" }}
|
||||
className="rounded-xl px-5 py-2 bg-slate-100 transition-all ease-linear"
|
||||
>
|
||||
<div className="flex space-x-2 pt-1 items-center text-sm overflow-hidden">
|
||||
{(submitted && submissionMessage == "All answers are correct!") &&
|
||||
<ReactConfetti
|
||||
numberOfPieces={submitted ? 1400 : 0}
|
||||
recycle={false}
|
||||
className="w-full h-screen"
|
||||
/>
|
||||
}
|
||||
<div className="flex space-x-2 items-center text-sm">
|
||||
<BadgeHelp className='text-slate-400' size={15} />
|
||||
<p className="uppercase tracking-widest text-xs font-bold py-1 text-slate-400">Quiz</p>
|
||||
</div>
|
||||
<div className="grow flex items-center justify-center">
|
||||
|
||||
</div>
|
||||
{isEditable ?
|
||||
<div>
|
||||
<button onClick={addSampleQuestion} className="bg-slate-200 hover:bg-slate-300 text-slate-800 font-bold py-1 px-2 rounded-lg text-xs">Add Question</button>
|
||||
</div>
|
||||
:
|
||||
<div className="flex space-x-1 items-center">
|
||||
<div onClick={() => refreshUserSubmission()} className="cursor-pointer px-2">
|
||||
<RefreshCcw className='text-slate-400 cursor-pointer' size={15} />
|
||||
</div>
|
||||
<button onClick={() => handleUserSubmission()} className="bg-slate-200 hover:bg-slate-300 text-slate-800 font-bold py-1 px-2 rounded-lg text-xs">Submit</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{questions.map((question: Question) => (
|
||||
<div key={question.question_id} className="pt-1 space-y-2">
|
||||
<div className="question">
|
||||
<div className="flex space-x-2 items-center">
|
||||
<div className="flex-grow">
|
||||
{isEditable ?
|
||||
<input value={question.question} placeholder="Your Question" onChange={(e) => changeQuestionValue(question.question_id, e.target.value)} className="text-slate-800 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-md font-bold w-full"></input>
|
||||
:
|
||||
<p className="text-slate-800 bg-[#00008b00] rounded-md text-md font-bold w-full">{question.question}</p>
|
||||
}
|
||||
</div>
|
||||
{isEditable &&
|
||||
<div
|
||||
onClick={() => deleteQuestion(question.question_id)}
|
||||
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer">
|
||||
<Minus
|
||||
className="mx-auto text-slate-400" size={12} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="answers flex py-2 space-x-3">
|
||||
{question.answers.map((answer: Answer) => (
|
||||
<div
|
||||
key={answer.answer_id}
|
||||
className={twMerge(
|
||||
'outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-s rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear',
|
||||
answer.correct && isEditable ? 'outline-lime-300' : 'outline-white',
|
||||
userAnswers.find((userAnswer: any) => (userAnswer.question_id === question.question_id && userAnswer.answer_id === answer.answer_id) && !isEditable) ? 'outline-slate-300' : '',
|
||||
(submitted && answer.correct) ? 'outline-lime-300 text-lime' : '',
|
||||
(submitted && !answer.correct) && userAnswers.find((userAnswer: any) => userAnswer.question_id === question.question_id && userAnswer.answer_id === answer.answer_id) ? 'outline-red-400' : '',
|
||||
)
|
||||
}
|
||||
onClick={() => handleAnswerClick(question.question_id, answer.answer_id)}
|
||||
>
|
||||
<div className={twMerge(
|
||||
"bg-white font-bold text-base flex items-center h-full w-[40px] rounded-l-md text-slate-800",
|
||||
answer.correct && isEditable ? 'bg-lime-300 text-lime-800 outline-none' : 'bg-white',
|
||||
(submitted && answer.correct) ? 'bg-lime-300 text-lime-800 outline-none' : '',
|
||||
(submitted && !answer.correct) && userAnswers.find((userAnswer: any) => userAnswer.question_id === question.question_id && userAnswer.answer_id === answer.answer_id) ? 'bg-red-400 text-red-800 outline-none' : '',
|
||||
)}>
|
||||
<p className="mx-auto font-bold text-sm ">{getAnswerID(question.answers.indexOf(answer),question.question_id)}</p>
|
||||
</div>
|
||||
{isEditable ?
|
||||
<input value={answer.answer} onChange={(e) => changeAnswerValue(question.question_id, answer.answer_id, e.target.value)} placeholder="Answer" className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-sm font-bold"></input>
|
||||
:
|
||||
<p className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] rounded-md ext-sm font-bold">{answer.answer}</p>
|
||||
}
|
||||
{isEditable &&
|
||||
<div className="flex space-x-1 items-center">
|
||||
<div
|
||||
onClick={() => markAnswerCorrect(question.question_id, answer.answer_id)}
|
||||
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-lime-300 hover:bg-lime-400 transition-all ease-linear text-sm cursor-pointer ">
|
||||
<Check
|
||||
className="mx-auto text-lime-800" size={12} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => deleteAnswer(question.question_id, answer.answer_id)}
|
||||
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer">
|
||||
<Minus
|
||||
className="mx-auto text-slate-400" size={12} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
))}
|
||||
{isEditable &&
|
||||
<div onClick={() => addAnswer(question.question_id)} className="outline outline-3 w-[30px] flex-none flex items-center h-[30px] outline-white hover:bg-opacity-100 hover:shadow-md rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear">
|
||||
<Plus className="mx-auto text-slate-800" size={15} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default QuizBlockComponent;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import VideoBlockComponent from "./VideoBlockComponent";
|
||||
|
||||
export default Node.create({
|
||||
name: "blockVideo",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
blockObject: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "block-video",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["block-video", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(VideoBlockComponent);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import { AlertTriangle, Image, Loader, Video } from "lucide-react";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { getBackendUrl } from "../../../../../services/config/config";
|
||||
import { uploadNewVideoFile } from "../../../../../services/blocks/Video/video";
|
||||
import { getActivityBlockMediaDirectory } from "@services/media/media";
|
||||
import { UploadIcon } from "@radix-ui/react-icons";
|
||||
|
||||
function VideoBlockComponents(props: any) {
|
||||
const [video, setVideo] = React.useState(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [blockObject, setblockObject] = React.useState(props.node.attrs.blockObject);
|
||||
const fileId = blockObject ? `${blockObject.block_data.file_id}.${blockObject.block_data.file_format}` : null;
|
||||
|
||||
const handleVideoChange = (event: React.ChangeEvent<any>) => {
|
||||
setVideo(event.target.files[0]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
let object = await uploadNewVideoFile(video, props.extension.options.activity.activity_id);
|
||||
setIsLoading(false);
|
||||
setblockObject(object);
|
||||
props.updateAttributes({
|
||||
blockObject: object,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="block-video">
|
||||
{!blockObject && (
|
||||
<BlockVideoWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={props.extension.options.editable}>
|
||||
{isLoading ? (
|
||||
<Loader className="animate-spin animate-pulse text-gray-200" size={50} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Video className="text-gray-200" size={50} />
|
||||
</div>
|
||||
<input className="p-3 rounded-lg file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 hover:file:cursor-pointer file:bg-gray-200 cursor-pointer file:text-gray-500" onChange={handleVideoChange} type="file" name="" id="" />
|
||||
<button className='p-2 px-3 bg-gray-200 rounded-lg text-gray-500 hover:bg-gray-300 transition space-x-2 items-center flex' onClick={handleSubmit}><UploadIcon></UploadIcon><p>Submit</p></button>
|
||||
</>
|
||||
)}
|
||||
</BlockVideoWrapper>
|
||||
)}
|
||||
{blockObject && (
|
||||
<BlockVideo>
|
||||
<video
|
||||
controls
|
||||
className="rounded-lg shadow h-96 w-full object-scale-down bg-black"
|
||||
src={`${getActivityBlockMediaDirectory(props.extension.options.activity.org_id,
|
||||
props.extension.options.activity.course_id,
|
||||
props.extension.options.activity.activity_id,
|
||||
blockObject.block_id,
|
||||
blockObject ? fileId : ' ', 'videoBlock')}`}
|
||||
></video>
|
||||
</BlockVideo>
|
||||
)}
|
||||
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
const BlockVideoWrapper = styled.div`
|
||||
|
||||
//border: ${(props) => (props.contentEditable ? "2px dashed #713f1117" : "none")};
|
||||
|
||||
// center
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const BlockVideo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
export default VideoBlockComponents;
|
||||
213
apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx
Normal file
213
apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import styled from "styled-components";
|
||||
import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon, DividerVerticalIcon, ListBulletIcon } from "@radix-ui/react-icons";
|
||||
import { AlertCircle, AlertTriangle, BadgeHelp, Code, FileText, GraduationCap, HelpCircle, ImagePlus, Info, ListChecks, Sigma, Video, Youtube } from "lucide-react";
|
||||
import ToolTip from "@components/StyledElements/Tooltip/Tooltip";
|
||||
|
||||
export const ToolbarButtons = ({ editor, props }: any) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// YouTube extension
|
||||
|
||||
const addYoutubeVideo = () => {
|
||||
const url = prompt("Enter YouTube URL");
|
||||
|
||||
if (url) {
|
||||
editor.commands.setYoutubeVideo({
|
||||
src: url,
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolButtonsWrapper>
|
||||
<ToolBtn onClick={() => editor.chain().focus().undo().run()}>
|
||||
<ArrowLeftIcon />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => editor.chain().focus().redo().run()}>
|
||||
<ArrowRightIcon />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive("bold") ? "is-active" : ""}>
|
||||
<FontBoldIcon />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive("italic") ? "is-active" : ""}>
|
||||
<FontItalicIcon />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive("strike") ? "is-active" : ""}>
|
||||
<StrikethroughIcon />
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive('orderedList') ? 'is-active' : ''}>
|
||||
<ListBulletIcon />
|
||||
</ToolBtn>
|
||||
<ToolSelect
|
||||
onChange={(e) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: parseInt(e.target.value) })
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<option value="1">Heading 1</option>
|
||||
<option value="2">Heading 2</option>
|
||||
<option value="3">Heading 3</option>
|
||||
<option value="4">Heading 4</option>
|
||||
<option value="5">Heading 5</option>
|
||||
<option value="6">Heading 6</option>
|
||||
</ToolSelect>
|
||||
{/* TODO: fix this : toggling only works one-way */}
|
||||
<DividerVerticalIcon style={{ marginTop: "auto", marginBottom: "auto", color: "grey" }} />
|
||||
<ToolTip content={"Info Callout"}>
|
||||
<ToolBtn onClick={() => editor.chain().focus().toggleNode("calloutInfo").run()}>
|
||||
<AlertCircle size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={"Warning Callout"}>
|
||||
<ToolBtn onClick={() => editor.chain().focus().toggleNode("calloutWarning").run()}>
|
||||
<AlertTriangle size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={"Image"}>
|
||||
<ToolBtn
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "blockImage",
|
||||
})
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<ImagePlus size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip
|
||||
content={"Video"}>
|
||||
<ToolBtn
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "blockVideo",
|
||||
})
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<Video size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={"YouTube video"}>
|
||||
<ToolBtn onClick={() => addYoutubeVideo()}>
|
||||
<Youtube size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={"Math Equation (LaTeX)"}>
|
||||
<ToolBtn
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "blockMathEquation",
|
||||
})
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<Sigma size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={"PDF Document"}>
|
||||
<ToolBtn
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "blockPDF",
|
||||
})
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<FileText size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={"Interactive Quiz"}>
|
||||
<ToolBtn
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "blockQuiz",
|
||||
})
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<BadgeHelp size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={"Code Block"}>
|
||||
<ToolBtn
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
className={editor.isActive('codeBlock') ? 'is-active' : ''}
|
||||
>
|
||||
<Code size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
</ToolButtonsWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolButtonsWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: left;
|
||||
justify-content: left;
|
||||
`;
|
||||
|
||||
const ToolBtn = styled.div`
|
||||
display: flex;
|
||||
background: rgba(217, 217, 217, 0.24);
|
||||
border-radius: 6px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
padding: 5px;
|
||||
margin-right: 5px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
svg {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(176, 176, 176, 0.5);
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 139, 139, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(217, 217, 217, 0.48);
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToolSelect = styled.select`
|
||||
display: flex;
|
||||
background: rgba(217, 217, 217, 0.185);
|
||||
border-radius: 6px;
|
||||
width: 100px;
|
||||
border: none;
|
||||
height: 25px;
|
||||
padding: 5px;
|
||||
font-size: 11px;
|
||||
font-family: "DM Sans";
|
||||
margin-right: 5px;
|
||||
`;
|
||||
33
apps/web/components/Objects/Loaders/PageLoading.tsx
Normal file
33
apps/web/components/Objects/Loaders/PageLoading.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const variants = {
|
||||
hidden: { opacity: 0, x: 0, y: 0 },
|
||||
enter: { opacity: 1, x: 0, y: 0 },
|
||||
exit: { opacity: 0, x: 0, y: 0 },
|
||||
};
|
||||
|
||||
function PageLoading() {
|
||||
|
||||
return (
|
||||
<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=""
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 py-20 transition-all">
|
||||
<div className="animate-pulse mx-auto flex space-x-4">
|
||||
<svg className="mx-auto" width="295" height="295" viewBox="0 0 295 295" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect opacity="0.51" x="6.5" y="6.5" width="282" height="282" rx="78.5" stroke="#454545" strokeOpacity="0.46" strokeWidth="13" strokeDasharray="11 11" />
|
||||
<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="#454545" fillOpacity="0.13" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageLoading
|
||||
102
apps/web/components/Objects/Menu/Menu.tsx
Normal file
102
apps/web/components/Objects/Menu/Menu.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
'use client';
|
||||
import React, { use, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { getAPIUrl, getUriWithOrg } from "@services/config/config";
|
||||
import { getOrganizationContextInfo, getOrganizationContextInfoWithoutCredentials } from "@services/organizations/orgs";
|
||||
import ClientComponentSkeleton from "@components/Utils/ClientComp";
|
||||
import { HeaderProfileBox } from "@components/Security/HeaderProfileBox";
|
||||
import MenuLinks from "./MenuLinks";
|
||||
import { getOrgLogoMediaDirectory } from "@services/media/media";
|
||||
import { MessageSquareIcon } from "lucide-react";
|
||||
import { Tooltip } from "@radix-ui/react-tooltip";
|
||||
import ToolTip from "@components/StyledElements/Tooltip/Tooltip";
|
||||
import Modal from "@components/StyledElements/Modal/Modal";
|
||||
import FeedbackModal from "../Modals/Feedback/Feedback";
|
||||
import useSWR from "swr";
|
||||
import { swrFetcher } from "@services/utils/ts/requests";
|
||||
|
||||
export const Menu = (props: any) => {
|
||||
const orgslug = props.orgslug;
|
||||
const [feedbackModal, setFeedbackModal] = React.useState(false);
|
||||
const { data: org, error: error, isLoading } = useSWR(`${getAPIUrl()}orgs/slug/${orgslug}`, swrFetcher);
|
||||
|
||||
function closeFeedbackModal() {
|
||||
setFeedbackModal(false);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="backdrop-blur-lg h-[60px] blur-3xl z-10" style={{
|
||||
}}>
|
||||
<div className="h-[150px] blur-3xl z-0" style={{
|
||||
background: "radial-gradient(1397.20% 56.18% at 75.99% 53.73%, rgba(253, 182, 207, 0.08) 0%, rgba(3, 110, 146, 0.08) 100%)"
|
||||
}}></div>
|
||||
|
||||
</div>
|
||||
<div className="backdrop-blur-lg bg-white/90 fixed flex top-0 left-0 right-0 h-[60px] ring-1 ring-inset ring-gray-500/10 items-center space-x-5 shadow-[0px_4px_16px_rgba(0,0,0,0.03)] z-50">
|
||||
<div className="flex items-center space-x-5 w-full max-w-screen-2xl mx-auto px-16">
|
||||
<div className="logo flex ">
|
||||
<Link href={getUriWithOrg(orgslug, "/")}>
|
||||
<div className="flex w-auto h-9 rounded-md items-center m-auto py-1 justify-center" >
|
||||
{org?.logo ? (
|
||||
<img
|
||||
src={`${getOrgLogoMediaDirectory(org.org_id, org?.logo)}`}
|
||||
alt="Learnhouse"
|
||||
style={{ width: "auto", height: "100%" }}
|
||||
className="rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<LearnHouseLogo></LearnHouseLogo>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="links flex grow">
|
||||
<MenuLinks orgslug={orgslug} />
|
||||
</div>
|
||||
<div className="profile flex items-center space-x-2">
|
||||
|
||||
<Modal
|
||||
isDialogOpen={feedbackModal}
|
||||
onOpenChange={setFeedbackModal}
|
||||
minHeight="sm"
|
||||
dialogContent={<FeedbackModal></FeedbackModal>}
|
||||
dialogTitle="Feedback"
|
||||
dialogDescription="An issue? A suggestion? a bug ? Let us know!"
|
||||
dialogTrigger={
|
||||
<div className="feedback cursor-pointer block items-center h-fit p-2 rounded-2xl bg-orange-800 hover:bg-orange-900 text-orange-300 shadow">
|
||||
<MessageSquareIcon size={12} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<HeaderProfileBox />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
}
|
||||
52
apps/web/components/Objects/Menu/MenuLinks.tsx
Normal file
52
apps/web/components/Objects/Menu/MenuLinks.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement';
|
||||
import { getUriWithOrg } from '@services/config/config';
|
||||
import Link from 'next/link';
|
||||
import React from 'react'
|
||||
|
||||
function MenuLinks(props: { orgslug: string }) {
|
||||
return (
|
||||
<div>
|
||||
<ul className="flex space-x-4">
|
||||
<LinkItem link="/courses" type="courses" orgslug={props.orgslug}></LinkItem>
|
||||
<LinkItem link="/collections" type="collections" orgslug={props.orgslug}></LinkItem>
|
||||
<AuthenticatedClientElement checkMethod='authentication'>
|
||||
<LinkItem link="/trail" type="trail" orgslug={props.orgslug}></LinkItem>
|
||||
</AuthenticatedClientElement>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const LinkItem = (props: any) => {
|
||||
const link = props.link;
|
||||
const orgslug = props.orgslug;
|
||||
return (
|
||||
<Link href={getUriWithOrg(orgslug, link)}>
|
||||
<li className="flex space-x-3 items-center text-[#909192] font-medium">
|
||||
{props.type == 'courses' &&
|
||||
<>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.9987 1.66663H6.66536C5.78131 1.66663 4.93346 2.01782 4.30834 2.64294C3.68322 3.26806 3.33203 4.1159 3.33203 4.99996V15C3.33203 15.884 3.68322 16.7319 4.30834 17.357C4.93346 17.9821 5.78131 18.3333 6.66536 18.3333H14.9987C15.4407 18.3333 15.8646 18.1577 16.1772 17.8451C16.4898 17.5326 16.6654 17.1087 16.6654 16.6666V3.33329C16.6654 2.89127 16.4898 2.46734 16.1772 2.15478C15.8646 1.84222 15.4407 1.66663 14.9987 1.66663ZM4.9987 4.99996C4.9987 4.55793 5.17429 4.13401 5.48685 3.82145C5.79941 3.50889 6.22334 3.33329 6.66536 3.33329H14.9987V11.6666H6.66536C6.0779 11.6691 5.50203 11.8303 4.9987 12.1333V4.99996ZM6.66536 16.6666C6.22334 16.6666 5.79941 16.491 5.48685 16.1785C5.17429 15.8659 4.9987 15.442 4.9987 15C4.9987 14.5579 5.17429 14.134 5.48685 13.8214C5.79941 13.5089 6.22334 13.3333 6.66536 13.3333H14.9987V16.6666H6.66536ZM8.33203 6.66663H11.6654C11.8864 6.66663 12.0983 6.57883 12.2546 6.42255C12.4109 6.26627 12.4987 6.05431 12.4987 5.83329C12.4987 5.61228 12.4109 5.40032 12.2546 5.24404C12.0983 5.08776 11.8864 4.99996 11.6654 4.99996H8.33203C8.11102 4.99996 7.89906 5.08776 7.74278 5.24404C7.5865 5.40032 7.4987 5.61228 7.4987 5.83329C7.4987 6.05431 7.5865 6.26627 7.74278 6.42255C7.89906 6.57883 8.11102 6.66663 8.33203 6.66663V6.66663Z" fill="#898A8B" />
|
||||
</svg>
|
||||
<span>Courses</span>
|
||||
</>}
|
||||
|
||||
{props.type == 'collections' &&
|
||||
<>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.0567 6.14171C17.0567 6.14171 17.0567 6.14171 17.0567 6.07504L17.0067 5.95004C16.9893 5.92352 16.9698 5.89844 16.9483 5.87504C16.926 5.83976 16.901 5.80632 16.8733 5.77504L16.7983 5.71671L16.665 5.65004L10.415 1.79171C10.2826 1.70893 10.1295 1.66504 9.97333 1.66504C9.81715 1.66504 9.66411 1.70893 9.53166 1.79171L3.33166 5.65004L3.25666 5.71671L3.18166 5.77504C3.15404 5.80632 3.12896 5.83976 3.10666 5.87504C3.08524 5.89844 3.06573 5.92352 3.04833 5.95004L2.99833 6.07504C2.99833 6.07504 2.99833 6.07504 2.99833 6.14171C2.99014 6.2137 2.99014 6.28639 2.99833 6.35838V13.6417C2.99805 13.7833 3.03386 13.9227 3.10239 14.0466C3.17092 14.1706 3.2699 14.275 3.39 14.35L9.64 18.2084C9.67846 18.2321 9.72076 18.2491 9.765 18.2584C9.765 18.2584 9.80666 18.2584 9.83166 18.2584C9.97265 18.3031 10.124 18.3031 10.265 18.2584C10.265 18.2584 10.3067 18.2584 10.3317 18.2584C10.3759 18.2491 10.4182 18.2321 10.4567 18.2084L16.665 14.35C16.7851 14.275 16.8841 14.1706 16.9526 14.0466C17.0211 13.9227 17.0569 13.7833 17.0567 13.6417V6.35838C17.0649 6.28639 17.0649 6.2137 17.0567 6.14171ZM9.165 16.0084L4.58166 13.175V7.85838L9.165 10.6834V16.0084ZM9.99833 9.24171L5.33166 6.35838L9.99833 3.48337L14.665 6.35838L9.99833 9.24171ZM15.415 13.175L10.8317 16.0084V10.6834L15.415 7.85838V13.175Z" fill="#898A8B" />
|
||||
</svg>
|
||||
<span>Collections</span>
|
||||
</>}
|
||||
|
||||
{props.type == 'trail' &&
|
||||
<>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.5751 7.95841C16.5059 7.82098 16.3999 7.70541 16.269 7.62451C16.1381 7.54361 15.9874 7.50054 15.8335 7.50008H11.6668V2.50008C11.6757 2.31731 11.6243 2.13669 11.5204 1.98608C11.4164 1.83547 11.2658 1.72325 11.0918 1.66674C10.9245 1.6117 10.744 1.61108 10.5763 1.66498C10.4087 1.71888 10.2624 1.82452 10.1585 1.96674L3.4918 11.1334C3.40827 11.2541 3.35811 11.3948 3.3464 11.5411C3.3347 11.6874 3.36186 11.8343 3.42513 11.9667C3.4834 12.1182 3.58462 12.2493 3.71637 12.3441C3.84812 12.4388 4.00467 12.493 4.1668 12.5001H8.33346V17.5001C8.33359 17.6758 8.38927 17.847 8.49254 17.9892C8.59581 18.1314 8.74139 18.2373 8.90846 18.2917C8.99219 18.3177 9.07915 18.3317 9.1668 18.3334C9.29828 18.3338 9.42799 18.303 9.5453 18.2436C9.66262 18.1842 9.76422 18.0979 9.8418 17.9917L16.5085 8.82508C16.5982 8.70074 16.652 8.55404 16.6637 8.40112C16.6755 8.24821 16.6448 8.09502 16.5751 7.95841ZM10.0001 14.9334V11.6667C10.0001 11.4457 9.91233 11.2338 9.75605 11.0775C9.59977 10.9212 9.38781 10.8334 9.1668 10.8334H5.83346L10.0001 5.06674V8.33341C10.0001 8.55442 10.0879 8.76638 10.2442 8.92267C10.4005 9.07895 10.6124 9.16674 10.8335 9.16674H14.1668L10.0001 14.9334Z" fill="#909192" />
|
||||
</svg>
|
||||
<span>Trail</span>
|
||||
</>}
|
||||
</li>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
export default MenuLinks
|
||||
160
apps/web/components/Objects/Menu/ProfileArea.tsx
Normal file
160
apps/web/components/Objects/Menu/ProfileArea.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
import Avvvatars from "avvvatars-react";
|
||||
import { GearIcon } from "@radix-ui/react-icons";
|
||||
import { getNewAccessTokenUsingRefreshToken, getUserInfo } from "@services/auth/auth";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import path from "path";
|
||||
|
||||
export interface Auth {
|
||||
access_token: string;
|
||||
isAuthenticated: boolean;
|
||||
userInfo: any;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ProfileArea() {
|
||||
|
||||
|
||||
const PRIVATE_ROUTES = ["/course/*/edit", "/settings*", "/trail"];
|
||||
const NON_AUTHENTICATED_ROUTES = ["/login", "/register"];
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [auth, setAuth] = React.useState<Auth>({ access_token: "", isAuthenticated: false, userInfo: {}, isLoading: true });
|
||||
|
||||
async function checkRefreshToken() {
|
||||
let data = await getNewAccessTokenUsingRefreshToken();
|
||||
if (data) {
|
||||
return data.access_token;
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
checkAuth();
|
||||
}, [pathname]);
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
let access_token = await checkRefreshToken();
|
||||
let userInfo = {};
|
||||
let isLoading = false;
|
||||
|
||||
if (access_token) {
|
||||
userInfo = await getUserInfo(access_token);
|
||||
setAuth({ access_token, isAuthenticated: true, userInfo, isLoading });
|
||||
|
||||
// Redirect to home if user is trying to access a NON_AUTHENTICATED_ROUTES route
|
||||
|
||||
if (NON_AUTHENTICATED_ROUTES.some((route) => new RegExp(`^${route.replace("*", ".*")}$`).test(pathname))) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
setAuth({ access_token, isAuthenticated: false, userInfo, isLoading });
|
||||
|
||||
// Redirect to login if user is trying to access a private route
|
||||
if (PRIVATE_ROUTES.some((route) => new RegExp(`^${route.replace("*", ".*")}$`).test(pathname))) {
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
}
|
||||
return (
|
||||
<ProfileAreaStyled>
|
||||
{!auth.isAuthenticated && (
|
||||
<UnidentifiedArea>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/login">
|
||||
Login
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/signup">
|
||||
Sign up
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</UnidentifiedArea>
|
||||
)}
|
||||
{auth.isAuthenticated && (
|
||||
<AccountArea>
|
||||
<div>{auth.userInfo.user_object.username}</div>
|
||||
<div>
|
||||
<Avvvatars value={auth.userInfo.user_object.user_id} style="shape" />
|
||||
</div>
|
||||
<Link href={"/settings"}><GearIcon /></Link>
|
||||
</AccountArea>
|
||||
)}
|
||||
</ProfileAreaStyled>
|
||||
)
|
||||
}
|
||||
|
||||
const AccountArea = styled.div`
|
||||
padding-right: 20px;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
|
||||
a{
|
||||
// center the gear icon
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 19px;
|
||||
background: #F5F5F5;
|
||||
|
||||
// hover effect
|
||||
&:hover{
|
||||
background: #E5E5E5;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
margin-right: 10px;
|
||||
}
|
||||
img {
|
||||
width: 29px;
|
||||
border-radius: 19px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProfileAreaStyled = styled.div`
|
||||
display: flex;
|
||||
place-items: stretch;
|
||||
place-items: center;
|
||||
`;
|
||||
|
||||
const UnidentifiedArea = styled.div`
|
||||
display: flex;
|
||||
place-items: stretch;
|
||||
flex-grow: 1;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
list-style: none;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
padding-right: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #171717;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
export default ProfileArea
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useState } from "react";
|
||||
import { ArrowLeftIcon, Cross1Icon } from "@radix-ui/react-icons";
|
||||
import DynamicPageActivityImage from "public/activities_types/dynamic-page-activity.png";
|
||||
import VideoPageActivityImage from "public//activities_types/video-page-activity.png";
|
||||
import DocumentPdfPageActivityImage from "public//activities_types/documentpdf-page-activity.png";
|
||||
import { styled, keyframes } from '@stitches/react';
|
||||
import DynamicCanvaModal from "./NewActivityModal/DynamicCanva";
|
||||
import VideoModal from "./NewActivityModal/Video";
|
||||
import Image from "next/image";
|
||||
import DocumentPdfModal from "./NewActivityModal/DocumentPdf";
|
||||
|
||||
function NewActivityModal({ closeModal, submitActivity, submitFileActivity, submitExternalVideo, chapterId }: any) {
|
||||
const [selectedView, setSelectedView] = useState("home");
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedView === "home" && (
|
||||
<ActivityChooserWrapper>
|
||||
<ActivityOption onClick={() => { setSelectedView("dynamic") }}>
|
||||
<ActivityTypeImage>
|
||||
<Image alt="Dynamic Page" src={DynamicPageActivityImage}></Image>
|
||||
</ActivityTypeImage>
|
||||
<ActivityTypeTitle>Dynamic Page</ActivityTypeTitle>
|
||||
</ActivityOption>
|
||||
<ActivityOption onClick={() => { setSelectedView("video") }}>
|
||||
<ActivityTypeImage>
|
||||
<Image alt="Video Page" src={VideoPageActivityImage}></Image>
|
||||
</ActivityTypeImage>
|
||||
<ActivityTypeTitle>Video Page</ActivityTypeTitle>
|
||||
</ActivityOption>
|
||||
<ActivityOption onClick={() => { setSelectedView("documentpdf") }}>
|
||||
<ActivityTypeImage>
|
||||
<Image alt="Document PDF Page" src={DocumentPdfPageActivityImage}></Image>
|
||||
</ActivityTypeImage>
|
||||
<ActivityTypeTitle>PDF Document Page</ActivityTypeTitle>
|
||||
</ActivityOption>
|
||||
</ActivityChooserWrapper>
|
||||
)}
|
||||
|
||||
{selectedView === "dynamic" && (
|
||||
<DynamicCanvaModal submitActivity={submitActivity} chapterId={chapterId} />
|
||||
)}
|
||||
|
||||
{selectedView === "video" && (
|
||||
<VideoModal submitFileActivity={submitFileActivity} submitExternalVideo={submitExternalVideo}
|
||||
chapterId={chapterId} />
|
||||
)}
|
||||
|
||||
{selectedView === "documentpdf" && (
|
||||
<DocumentPdfModal submitFileActivity={submitFileActivity} chapterId={chapterId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ActivityChooserWrapper = styled("div", {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "start",
|
||||
marginTop: 10,
|
||||
});
|
||||
|
||||
const ActivityOption = styled("div", {
|
||||
width: "180px",
|
||||
textAlign: "center",
|
||||
borderRadius: 10,
|
||||
background: "#F6F6F6",
|
||||
border: "4px solid #F5F5F5",
|
||||
margin: "auto",
|
||||
|
||||
// hover
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
background: "#ededed",
|
||||
border: "4px solid #ededed",
|
||||
|
||||
transition: "background 0.2s ease-in-out, border 0.2s ease-in-out",
|
||||
},
|
||||
});
|
||||
|
||||
const ActivityTypeImage = styled("div", {
|
||||
height: 80,
|
||||
borderRadius: 8,
|
||||
margin: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "end",
|
||||
textAlign: "center",
|
||||
background: "#ffffff",
|
||||
|
||||
// hover
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
|
||||
const ActivityTypeTitle = styled("div", {
|
||||
display: "flex",
|
||||
fontSize: 12,
|
||||
height: "20px",
|
||||
fontWeight: 500,
|
||||
color: "rgba(0, 0, 0, 0.38);",
|
||||
|
||||
// center text vertically
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
|
||||
});
|
||||
|
||||
export default NewActivityModal;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import FormLayout, { ButtonBlack, Flex, FormField, FormLabel, FormMessage, Input, Textarea } from "@components/StyledElements/Form/Form";
|
||||
import React, { useState } from "react";
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import BarLoader from "react-spinners/BarLoader";
|
||||
|
||||
function DocumentPdfModal({ submitFileActivity, chapterId }: any) {
|
||||
const [documentpdf, setDocumentPdf] = React.useState(null) as any;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
|
||||
const handleDocumentPdfChange = (event: React.ChangeEvent<any>) => {
|
||||
setDocumentPdf(event.target.files[0]);
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
let status = await submitFileActivity(documentpdf, "documentpdf", { name, type: "documentpdf" }, chapterId);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout onSubmit={handleSubmit}>
|
||||
<FormField name="documentpdf-activity-name">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>PDF Document name</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a name for your PDF Document activity</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleNameChange} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="documentpdf-activity-file">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>PDF Document file</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a PDF Document for your activity</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<input type="file" onChange={handleDocumentPdfChange} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{borderRadius:60,}} width={60} color="#ffffff" /> : "Create activity"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentPdfModal;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import FormLayout, { ButtonBlack, Flex, FormField, FormLabel, FormMessage, Input, Textarea } from "@components/StyledElements/Form/Form";
|
||||
import React, { useState } from "react";
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import BarLoader from "react-spinners/BarLoader";
|
||||
|
||||
function DynamicCanvaModal({ submitActivity, chapterId }: any) {
|
||||
const [activityName, setActivityName] = useState("");
|
||||
const [activityDescription, setActivityDescription] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleActivityNameChange = (e: any) => {
|
||||
setActivityName(e.target.value);
|
||||
};
|
||||
|
||||
const handleActivityDescriptionChange = (e: any) => {
|
||||
setActivityDescription(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
await submitActivity({
|
||||
name: activityName,
|
||||
chapterId: chapterId,
|
||||
type: "dynamic",
|
||||
org_id : "test",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
return (
|
||||
<FormLayout onSubmit={handleSubmit}>
|
||||
<FormField name="dynamic-activity-name">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Activity name</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a name for your activity</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleActivityNameChange} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="dynamic-activity-desc">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Activity description</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a description for your activity</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Textarea onChange={handleActivityDescriptionChange} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{borderRadius:60,}} width={60} color="#ffffff" />
|
||||
: "Create activity"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicCanvaModal;
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import FormLayout, { ButtonBlack, Flex, FormField, FormLabel, FormMessage, Input, Textarea } from "@components/StyledElements/Form/Form";
|
||||
import React, { useState } from "react";
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import BarLoader from "react-spinners/BarLoader";
|
||||
import { Youtube } from "lucide-react";
|
||||
|
||||
interface ExternalVideoObject {
|
||||
name: string,
|
||||
type: string,
|
||||
uri: string
|
||||
}
|
||||
|
||||
|
||||
function VideoModal({ submitFileActivity, submitExternalVideo, chapterId }: any) {
|
||||
const [video, setVideo] = React.useState(null) as any;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [youtubeUrl, setYoutubeUrl] = React.useState("");
|
||||
const [selectedView, setSelectedView] = React.useState("file") as any;
|
||||
|
||||
const handleVideoChange = (event: React.ChangeEvent<any>) => {
|
||||
setVideo(event.target.files[0]);
|
||||
};
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value);
|
||||
};
|
||||
|
||||
const handleYoutubeUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setYoutubeUrl(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (selectedView === "file") {
|
||||
let status = await submitFileActivity(video, "video", { name, type: "video" }, chapterId);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
if (selectedView === "youtube") {
|
||||
let external_video_object: ExternalVideoObject = {
|
||||
name,
|
||||
type: "youtube",
|
||||
uri: youtubeUrl
|
||||
}
|
||||
let status = await submitExternalVideo(external_video_object, 'activity' ,chapterId);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/* TODO : implement some sort of progress bar for file uploads, it is not possible yet because i'm not using axios.
|
||||
and the actual upload isn't happening here anyway, it's in the submitFileActivity function */
|
||||
|
||||
return (
|
||||
<FormLayout onSubmit={handleSubmit}>
|
||||
<FormField name="video-activity-name">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Video name</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a name for your video activity</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleNameChange} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<div className="flex flex-col rounded-md bg-gray-50 outline-dashed outline-gray-200">
|
||||
<div className="">
|
||||
<div className="flex m-4 justify-center space-x-2 mb-0">
|
||||
<div onClick={() => { setSelectedView("file") }} className="rounded-full bg-slate-900 text-zinc-50 py-2 px-4 text-sm drop-shadow-md hover:cursor-pointer hover:bg-slate-700 ">Video upload</div>
|
||||
<div onClick={() => { setSelectedView("youtube") }} className="rounded-full bg-slate-900 text-zinc-50 py-2 px-4 text-sm drop-shadow-md hover:cursor-pointer hover:bg-slate-700">YouTube Video</div>
|
||||
</div>
|
||||
{selectedView === "file" && (<div className="p-4 justify-center m-auto align-middle">
|
||||
<FormField name="video-activity-file">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Video file</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a video for your activity</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<input type="file" onChange={handleVideoChange} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
</div>)}
|
||||
{selectedView === "youtube" && (
|
||||
<div className="p-4 justify-center m-auto align-middle">
|
||||
<FormField name="video-activity-file">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel className="flex justify-center align-middle"><Youtube className="m-auto pr-1" /><span className="flex">YouTube URL</span></FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a video for your activity</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input className="bg-white" onChange={handleYoutubeUrlChange} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack className="bg-black" type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{ borderRadius: 60, }} width={60} color="#ffffff" /> : "Create activity"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default VideoModal;
|
||||
61
apps/web/components/Objects/Modals/Chapters/NewChapter.tsx
Normal file
61
apps/web/components/Objects/Modals/Chapters/NewChapter.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import FormLayout, { Flex, FormField, Input, Textarea, FormLabel, ButtonBlack } from "@components/StyledElements/Form/Form";
|
||||
import { FormMessage } from "@radix-ui/react-form";
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import React, { useState } from "react";
|
||||
import BarLoader from "react-spinners/BarLoader";
|
||||
|
||||
function NewChapterModal({ submitChapter, closeModal }: any) {
|
||||
const [chapterName, setChapterName] = useState("");
|
||||
const [chapterDescription, setChapterDescription] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleChapterNameChange = (e: any) => {
|
||||
setChapterName(e.target.value);
|
||||
};
|
||||
|
||||
const handleChapterDescriptionChange = (e: any) => {
|
||||
setChapterDescription(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsSubmitting(true);
|
||||
await submitChapter({ name: chapterName, description: chapterDescription, activities: [] });
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout onSubmit={handleSubmit}>
|
||||
<FormField name="chapter-name">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Chapter name</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a chapter name</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleChapterNameChange} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="chapter-desc">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Chapter description</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a chapter description</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Textarea onChange={handleChapterDescriptionChange} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{borderRadius:60,}} width={60} color="#ffffff" />
|
||||
: "Create Chapter"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewChapterModal;
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import FormLayout, { ButtonBlack, Flex, FormField, FormLabel, Input, Textarea } from '@components/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { FormMessage } from "@radix-ui/react-form";
|
||||
import { createNewCourse } from '@services/courses/courses';
|
||||
import { getOrganizationContextInfoWithoutCredentials } from '@services/organizations/orgs';
|
||||
import React, { useState } from 'react'
|
||||
import { BarLoader } from 'react-spinners'
|
||||
import { revalidateTags } from '@services/utils/ts/requests';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
function CreateCourseModal({ closeModal, orgslug }: any) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [thumbnail, setThumbnail] = React.useState(null) as any;
|
||||
const router = useRouter();
|
||||
|
||||
const [orgId, setOrgId] = React.useState(null) as any;
|
||||
|
||||
|
||||
const getOrgMetadata = async () => {
|
||||
const org = await getOrganizationContextInfoWithoutCredentials(orgslug, { revalidate: 360, tags: ['organizations'] });
|
||||
setOrgId(org.org_id);
|
||||
}
|
||||
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value);
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (event: React.ChangeEvent<any>) => {
|
||||
setDescription(event.target.value);
|
||||
};
|
||||
|
||||
const handleThumbnailChange = (event: React.ChangeEvent<any>) => {
|
||||
setThumbnail(event.target.files[0]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
let status = await createNewCourse(orgId, { name, description }, thumbnail);
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (status.org_id == orgId) {
|
||||
closeModal();
|
||||
router.refresh();
|
||||
await revalidateTags(['courses'], orgslug);
|
||||
|
||||
// refresh page (FIX for Next.js BUG)
|
||||
// window.location.reload();
|
||||
} else {
|
||||
alert("Error creating course, please see console logs");
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (orgslug) {
|
||||
getOrgMetadata();
|
||||
}
|
||||
}, [isLoading, orgslug]);
|
||||
|
||||
return (
|
||||
<FormLayout onSubmit={handleSubmit}>
|
||||
<FormField name="course-name">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course name</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a course name</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleNameChange} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="course-desc">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course description</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a course description</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Textarea onChange={handleDescriptionChange} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="course-thumbnail">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course thumbnail</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide a thumbnail for your course</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleThumbnailChange} type="file" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="course-learnings">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course keywords</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide learning elements, separated by comma (,)</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Textarea required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{ borderRadius: 60, }} width={60} color="#ffffff" />
|
||||
: "Create Course"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</FormLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateCourseModal
|
||||
88
apps/web/components/Objects/Modals/Feedback/Feedback.tsx
Normal file
88
apps/web/components/Objects/Modals/Feedback/Feedback.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import FormLayout, { ButtonBlack, Flex, FormField, FormLabel, FormMessage, Input, Textarea } from "@components/StyledElements/Form/Form"
|
||||
import { BarLoader } from "react-spinners"
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import React, { useState } from "react";
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { CheckCircleIcon } from "lucide-react";
|
||||
import { AuthContext } from "@components/Security/AuthProvider";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export const FeedbackModal = (user: any) => {
|
||||
const auth: any = React.useContext(AuthContext);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [view, setView] = useState<"feedbackForm" | "success">("feedbackForm")
|
||||
const [feedbackMessage, setFeedbackMessage] = useState("");
|
||||
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const user = auth.userInfo.user_object ? auth.userInfo.user_object : null;
|
||||
const eventId = Sentry.captureMessage(`Feedback from ${user ? user.email : 'Anonymous'} - ${feedbackMessage}`);
|
||||
|
||||
const userFeedback = {
|
||||
event_id: eventId,
|
||||
name: user ? user.full_name : 'Anonymous',
|
||||
email: user ? user.email : 'Anonymous',
|
||||
comments: feedbackMessage,
|
||||
}
|
||||
Sentry.captureUserFeedback(userFeedback);
|
||||
setIsSubmitting(false);
|
||||
setView("success");
|
||||
};
|
||||
|
||||
const handleFeedbackMessage = (event: React.ChangeEvent<any>) => {
|
||||
setFeedbackMessage(event.target.value)
|
||||
};
|
||||
|
||||
if (view == "feedbackForm") {
|
||||
return (
|
||||
<FormLayout onSubmit={handleSubmit}>
|
||||
<FormField name="feedback-message">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Feedback message</FormLabel>
|
||||
<FormMessage match="valueMissing">Please provide learning elements, separated by comma (,)</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Textarea style={{ height: 150, }} onChange={handleFeedbackMessage} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? <BarLoader cssOverride={{ borderRadius: 60, }} width={60} color="#ffffff" />
|
||||
: "Submit Feedback"}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</FormLayout>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-5">
|
||||
<div className="flex flex-col items-center space-y-5 pt-10">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-9xl text-green-500">
|
||||
<CheckCircleIcon></CheckCircleIcon>
|
||||
</div>
|
||||
<div className="text-3xl text-green-500">
|
||||
<div>Thank you for your feedback!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl text-gray-500">
|
||||
<div>We will take it into account.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonBlack onClick={() => setView("feedbackForm")}>Send another feedback</ButtonBlack>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default FeedbackModal
|
||||
77
apps/web/components/Objects/Other/CollectionThumbnail.tsx
Normal file
77
apps/web/components/Objects/Other/CollectionThumbnail.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { deleteCollection } from '@services/courses/collections'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
import { revalidateTags } from '@services/utils/ts/requests'
|
||||
import { X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
type PropsType = {
|
||||
collection: any,
|
||||
orgslug: string,
|
||||
org_id: string
|
||||
}
|
||||
|
||||
const removeCollectionPrefix = (collectionid: string) => {
|
||||
return collectionid.replace("collection_", "")
|
||||
}
|
||||
|
||||
function CollectionThumbnail(props: PropsType) {
|
||||
return (
|
||||
<div className=''>
|
||||
<div className="flex flex-row space-x-4 inset-0 ring-1 ring-inset my-auto ring-black/10 rounded-xl shadow-xl relative w-[300px] h-[80px] bg-cover items-center justify-center bg-indigo-600 font-bold text-zinc-50" >
|
||||
<div className="flex -space-x-5">
|
||||
{props.collection.courses.slice(0, 2).map((course: any) => (
|
||||
<>
|
||||
<Link href={getUriWithOrg(props.orgslug, "/collection/" + removeCollectionPrefix(props.collection.collection_id))}>
|
||||
<div className="inset-0 rounded-full shadow-2xl bg-cover w-12 h-8 justify-center ring-indigo-800 ring-4" style={{ backgroundImage: `url(${getCourseThumbnailMediaDirectory(props.collection.org_id, course.course_id, course.thumbnail)})` }}>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<Link href={getUriWithOrg(props.orgslug, "/collection/" + removeCollectionPrefix(props.collection.collection_id))}>
|
||||
<h1 className="font-bold text-md justify-center">{props.collection.name}</h1>
|
||||
</Link>
|
||||
<CollectionAdminEditsArea orgslug={props.orgslug} org_id={props.org_id} collection_id={props.collection.collection_id} collection={props.collection} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CollectionAdminEditsArea = (props: any) => {
|
||||
const router = useRouter();
|
||||
|
||||
const deleteCollectionUI = async (collectionId: number) => {
|
||||
await deleteCollection(collectionId);
|
||||
await revalidateTags(["collections"], props.orgslug);
|
||||
// reload the page
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticatedClientElement orgId={props.org_id} checkMethod='roles'>
|
||||
<div className="flex space-x-1 justify-center mx-auto z-20 ">
|
||||
<ConfirmationModal
|
||||
confirmationMessage="Are you sure you want to delete this collection?"
|
||||
confirmationButtonText="Delete Collection"
|
||||
dialogTitle={"Delete " + props.collection.name + " ?"}
|
||||
dialogTrigger={
|
||||
<div
|
||||
className="hover:cursor-pointer p-1 px-2 bg-red-600 rounded-xl items-center justify-center flex shadow-xl"
|
||||
rel="noopener noreferrer">
|
||||
<X size={10} className="text-rose-200 font-bold" />
|
||||
</div>}
|
||||
functionToExecute={() => deleteCollectionUI(props.collection_id)}
|
||||
status='warning'
|
||||
></ConfirmationModal>
|
||||
</div>
|
||||
</AuthenticatedClientElement>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionThumbnail
|
||||
75
apps/web/components/Objects/Other/CourseThumbnail.tsx
Normal file
75
apps/web/components/Objects/Other/CourseThumbnail.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement';
|
||||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
|
||||
import { getUriWithOrg } from '@services/config/config';
|
||||
import { deleteCourseFromBackend } from '@services/courses/courses';
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
|
||||
import { revalidateTags } from '@services/utils/ts/requests';
|
||||
import { FileEdit, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
|
||||
type PropsType = {
|
||||
course: any,
|
||||
orgslug: string
|
||||
}
|
||||
|
||||
// function to remove "course_" from the course_id
|
||||
function removeCoursePrefix(course_id: string) {
|
||||
return course_id.replace("course_", "");
|
||||
}
|
||||
|
||||
function CourseThumbnail(props: PropsType) {
|
||||
const router = useRouter();
|
||||
|
||||
async function deleteCourses(course_id: any) {
|
||||
await deleteCourseFromBackend(course_id);
|
||||
await revalidateTags(['courses'], props.orgslug);
|
||||
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<AdminEditsArea course={props.course} orgSlug={props.orgslug} courseId={props.course.course_id} deleteCourses={deleteCourses} />
|
||||
<Link href={getUriWithOrg(props.orgslug, "/course/" + removeCoursePrefix(props.course.course_id))}>
|
||||
<div className="inset-0 ring-1 ring-inset ring-black/10 rounded-xl shadow-xl w-[249px] h-[131px] bg-cover" style={{ backgroundImage: `url(${getCourseThumbnailMediaDirectory(props.course.org_id, props.course.course_id, props.course.thumbnail)})` }}>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
<h2 className="font-bold text-lg w-[250px] py-2">{props.course.name}</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AdminEditsArea = (props: { orgSlug: string, courseId: string, course: any, deleteCourses: any }) => {
|
||||
return (
|
||||
<AuthenticatedClientElement checkMethod='roles' orgId={props.course.org_id}>
|
||||
<div className="flex space-x-1 absolute justify-center mx-auto z-20 bottom-14 left-1/2 transform -translate-x-1/2">
|
||||
<Link href={getUriWithOrg(props.orgSlug, "/course/" + removeCoursePrefix(props.courseId) + "/edit")}>
|
||||
<div
|
||||
className=" hover:cursor-pointer p-1 px-4 bg-orange-600 rounded-xl items-center justify-center flex shadow-xl"
|
||||
rel="noopener noreferrer">
|
||||
<FileEdit size={14} className="text-orange-200 font-bold" />
|
||||
</div>
|
||||
</Link>
|
||||
<ConfirmationModal
|
||||
confirmationButtonText='Delete Course'
|
||||
confirmationMessage='Are you sure you want to delete this course?'
|
||||
dialogTitle={'Delete ' + props.course.name + ' ?'}
|
||||
dialogTrigger={
|
||||
<div
|
||||
className=" hover:cursor-pointer p-1 px-4 bg-red-600 rounded-xl items-center justify-center flex shadow-xl"
|
||||
rel="noopener noreferrer">
|
||||
<X size={14} className="text-rose-200 font-bold" />
|
||||
</div>}
|
||||
functionToExecute={() => props.deleteCourses(props.courseId)}
|
||||
status='warning'
|
||||
></ConfirmationModal>
|
||||
</div>
|
||||
</AuthenticatedClientElement>
|
||||
)
|
||||
}
|
||||
|
||||
export default CourseThumbnail
|
||||
112
apps/web/components/Pages/CourseEdit/Draggables/Activity.tsx
Normal file
112
apps/web/components/Pages/CourseEdit/Draggables/Activity.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
import { getAPIUrl, getUriWithOrg } from "@services/config/config";
|
||||
import { FileText, Video, Sparkles, X, Pencil, MoreVertical, Eye, Save, File } from "lucide-react";
|
||||
import { mutate } from "swr";
|
||||
import { revalidateTags } from "@services/utils/ts/requests";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ConfirmationModal from "@components/StyledElements/ConfirmationModal/ConfirmationModal";
|
||||
import { deleteActivity, updateActivity } from "@services/courses/activities";
|
||||
|
||||
interface ModifiedActivityInterface {
|
||||
activityId: string;
|
||||
activityName: string;
|
||||
}
|
||||
|
||||
function Activity(props: any) {
|
||||
const router = useRouter();
|
||||
const [modifiedActivity, setModifiedActivity] = React.useState<ModifiedActivityInterface | undefined>(undefined);
|
||||
const [selectedActivity, setSelectedActivity] = React.useState<string | undefined>(undefined);
|
||||
|
||||
async function removeActivity() {
|
||||
await deleteActivity(props.activity.id);
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}`);
|
||||
await revalidateTags(['courses'], props.orgslug);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function updateActivityName(activityId: string) {
|
||||
if ((modifiedActivity?.activityId === activityId) && selectedActivity !== undefined) {
|
||||
setSelectedActivity(undefined);
|
||||
let modifiedActivityCopy = {
|
||||
name: modifiedActivity.activityName,
|
||||
description: '',
|
||||
type: props.activity.type,
|
||||
content: props.activity.content,
|
||||
}
|
||||
|
||||
await updateActivity(modifiedActivityCopy, activityId)
|
||||
await mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable key={props.activity.id} draggableId={props.activity.id} index={props.index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className="flex flex-row py-2 my-2 rounded-md bg-gray-50 text-gray-500 hover:bg-gray-100 hover:scale-102 hover:shadow space-x-1 w-auto items-center ring-1 ring-inset ring-gray-400/10 shadow-sm transition-all delay-100 duration-75 ease-linear" key={props.activity.id} {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef}>
|
||||
<div className="px-3 text-gray-300 space-x-1 w-28" >
|
||||
{props.activity.type === "video" && <>
|
||||
<div className="flex space-x-2 items-center"><Video size={16} /> <div className="text-xs bg-gray-200 text-gray-400 font-bold px-2 py-1 rounded-full mx-auto justify-center align-middle">Video</div> </div></>}
|
||||
{props.activity.type === "documentpdf" && <><div className="flex space-x-2 items-center"><div className="w-[30px]"><File size={16} /> </div><div className="text-xs bg-gray-200 text-gray-400 font-bold px-2 py-1 rounded-full">Document</div> </div></>}
|
||||
{props.activity.type === "dynamic" && <><div className="flex space-x-2 items-center"><Sparkles size={16} /> <div className="text-xs bg-gray-200 text-gray-400 font-bold px-2 py-1 rounded-full">Dynamic</div> </div></>}
|
||||
</div>
|
||||
|
||||
<div className="grow items-center space-x-2 flex mx-auto justify-center">
|
||||
|
||||
{selectedActivity === props.activity.id ?
|
||||
(<div className="chapter-modification-zone text-[7px] text-gray-600 shadow-inner bg-gray-200/60 py-1 px-4 rounded-lg space-x-3">
|
||||
<input type="text" className="bg-transparent outline-none text-xs text-gray-500" placeholder="Activity name" value={modifiedActivity ? modifiedActivity?.activityName : props.activity.name} onChange={(e) => setModifiedActivity({ activityId: props.activity.id, activityName: e.target.value })} />
|
||||
<button onClick={() => updateActivityName(props.activity.id)} className="bg-transparent text-neutral-700 hover:cursor-pointer hover:text-neutral-900">
|
||||
<Save size={11} onClick={() => updateActivityName(props.activity.id)} />
|
||||
</button>
|
||||
</div>) : (<p className="first-letter:uppercase"> {props.activity.name} </p>)}
|
||||
<Pencil onClick={() => setSelectedActivity(props.activity.id)}
|
||||
size={12} className="text-neutral-400 hover:cursor-pointer" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row space-x-2">
|
||||
{props.activity.type === "dynamic" && <>
|
||||
<Link
|
||||
href={getUriWithOrg(props.orgslug, "") + `/course/${props.courseid}/activity/${props.activity.id.replace("activity_", "")}/edit`}
|
||||
className=" hover:cursor-pointer p-1 px-3 bg-sky-700 rounded-md items-center"
|
||||
rel="noopener noreferrer">
|
||||
<div className="text-sky-100 font-bold text-xs" >Edit </div>
|
||||
</Link>
|
||||
</>}
|
||||
<Link
|
||||
href={getUriWithOrg(props.orgslug, "") + `/course/${props.courseid}/activity/${props.activity.id.replace("activity_", "")}`}
|
||||
className=" hover:cursor-pointer p-1 px-3 bg-gray-200 rounded-md"
|
||||
rel="noopener noreferrer">
|
||||
<Eye strokeWidth={2} size={15} className="text-gray-600" />
|
||||
</Link>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div className="flex flex-row pr-3 space-x-1 items-center">
|
||||
<MoreVertical size={15} className="text-gray-300" />
|
||||
<ConfirmationModal
|
||||
confirmationMessage="Are you sure you want to delete this activity ?"
|
||||
confirmationButtonText="Delete Activity"
|
||||
dialogTitle={"Delete " + props.activity.name + " ?"}
|
||||
dialogTrigger={
|
||||
<div
|
||||
className=" hover:cursor-pointer p-1 px-5 bg-red-600 rounded-md"
|
||||
rel="noopener noreferrer">
|
||||
<X size={15} className="text-rose-200 font-bold" />
|
||||
</div>}
|
||||
functionToExecute={() => removeActivity()}
|
||||
status='warning'
|
||||
></ConfirmationModal></div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default Activity;
|
||||
130
apps/web/components/Pages/CourseEdit/Draggables/Chapter.tsx
Normal file
130
apps/web/components/Pages/CourseEdit/Draggables/Chapter.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
|
||||
import Activity from "./Activity";
|
||||
import { Folders, Hexagon, MoreVertical, Pencil, PlusSquare, Save, Sparkle, Sparkles, Trash, Trash2, X } from "lucide-react";
|
||||
import ConfirmationModal from "@components/StyledElements/ConfirmationModal/ConfirmationModal";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateChapter } from "@services/courses/chapters";
|
||||
import { mutate } from "swr";
|
||||
import { getAPIUrl } from "@services/config/config";
|
||||
import { revalidateTags } from "@services/utils/ts/requests";
|
||||
|
||||
interface ModifiedChapterInterface {
|
||||
chapterId: string;
|
||||
chapterName: string;
|
||||
}
|
||||
|
||||
function Chapter(props: any) {
|
||||
const router = useRouter();
|
||||
const [modifiedChapter, setModifiedChapter] = React.useState<ModifiedChapterInterface | undefined>(undefined);
|
||||
const [selectedChapter, setSelectedChapter] = React.useState<string | undefined>(undefined);
|
||||
|
||||
async function updateChapterName(chapterId: string) {
|
||||
if (modifiedChapter?.chapterId === chapterId) {
|
||||
setSelectedChapter(undefined);
|
||||
let modifiedChapterCopy = {
|
||||
name: modifiedChapter.chapterName,
|
||||
description: '',
|
||||
activities: props.info.list.chapter.activityIds,
|
||||
}
|
||||
await updateChapter(chapterId, modifiedChapterCopy)
|
||||
await mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable key={props.info.list.chapter.id} draggableId={props.info.list.chapter.id} index={props.index}>
|
||||
{(provided, snapshot) => (
|
||||
<ChapterWrapper
|
||||
{...provided.dragHandleProps}
|
||||
{...provided.draggableProps}
|
||||
ref={provided.innerRef}
|
||||
// isDragging={snapshot.isDragging}
|
||||
className="max-w-screen-2xl mx-auto bg-white px-5"
|
||||
key={props.info.list.chapter.id}
|
||||
>
|
||||
<div className="flex pt-3 pr-3 font-bold text-md items-center space-x-2">
|
||||
<div className="flex grow text-lg space-x-3 items-center rounded-md px-3 py-1">
|
||||
<div className="bg-neutral-100 rounded-md p-2">
|
||||
<Hexagon strokeWidth={3} size={16} className="text-neutral-600 " />
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 items-center">
|
||||
|
||||
{selectedChapter === props.info.list.chapter.id ?
|
||||
(<div className="chapter-modification-zone bg-neutral-100 py-1 px-4 rounded-lg space-x-3">
|
||||
<input type="text" className="bg-transparent outline-none text-sm text-neutral-700" placeholder="Chapter name" value={modifiedChapter ? modifiedChapter?.chapterName : props.info.list.chapter.name} onChange={(e) => setModifiedChapter({ chapterId: props.info.list.chapter.id, chapterName: e.target.value })} />
|
||||
<button onClick={() => updateChapterName(props.info.list.chapter.id)} className="bg-transparent text-neutral-700 hover:cursor-pointer hover:text-neutral-900">
|
||||
<Save size={15} onClick={() => updateChapterName(props.info.list.chapter.id)} />
|
||||
</button>
|
||||
</div>) : (<p className="text-neutral-700 first-letter:uppercase">{props.info.list.chapter.name}</p>)}
|
||||
<Pencil size={15} className="text-neutral-600 hover:cursor-pointer" onClick={() => setSelectedChapter(props.info.list.chapter.id)} />
|
||||
</div>
|
||||
</div>
|
||||
<MoreVertical size={15} className="text-gray-300" />
|
||||
<ConfirmationModal
|
||||
confirmationButtonText="Delete Chapter"
|
||||
confirmationMessage="Are you sure you want to delete this chapter?"
|
||||
dialogTitle={"Delete " + props.info.list.chapter.name + " ?"}
|
||||
dialogTrigger={
|
||||
<div
|
||||
className=" hover:cursor-pointer p-1 px-4 bg-red-600 rounded-md shadow flex space-x-1 items-center text-rose-100 text-sm"
|
||||
rel="noopener noreferrer">
|
||||
<X size={15} className="text-rose-200 font-bold" />
|
||||
<p>Delete Chapter</p>
|
||||
</div>}
|
||||
functionToExecute={() => props.deleteChapter(props.info.list.chapter.id)}
|
||||
status='warning'
|
||||
></ConfirmationModal>
|
||||
|
||||
</div>
|
||||
<Droppable key={props.info.list.chapter.id} droppableId={props.info.list.chapter.id} type="activity">
|
||||
{(provided) => (
|
||||
<ActivitiesList {...provided.droppableProps} ref={provided.innerRef}>
|
||||
<div className="flex flex-col">
|
||||
{props.info.list.activities.map((activity: any, index: any) => (
|
||||
<Activity orgslug={props.orgslug} courseid={props.courseid} key={activity.id} activity={activity} index={index}></Activity>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
|
||||
<div onClick={() => {
|
||||
props.openNewActivityModal(props.info.list.chapter.id);
|
||||
}} className="flex space-x-2 items-center py-2 my-3 rounded-md justify-center text-white bg-black hover:cursor-pointer">
|
||||
<Sparkles className="" size={17} />
|
||||
<div className="text-sm mx-auto my-auto items-center font-bold">Add Activity + </div>
|
||||
</div>
|
||||
</div>
|
||||
</ActivitiesList>
|
||||
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
</ChapterWrapper>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
const ChapterWrapper = styled.div`
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
font-size: 15px;
|
||||
display: block;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.19);
|
||||
box-shadow: 0px 13px 33px -13px rgb(0 0 0 / 12%);
|
||||
transition: all 0.2s ease;
|
||||
h3{
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActivitiesList = styled.div`
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
export default Chapter;
|
||||
20
apps/web/components/Pages/CourseEdit/Draggables/data.ts
Normal file
20
apps/web/components/Pages/CourseEdit/Draggables/data.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export const initialData = {
|
||||
activities: {
|
||||
"activity-1": { id: "activity-1", content: "First activity" },
|
||||
"activity-2": { id: "activity-2", content: "Second activity" },
|
||||
"activity-3": { id: "activity-3", content: "Third activity" },
|
||||
"activity-4": { id: "activity-4", content: "Fourth activity" },
|
||||
"activity-5": { id: "activity-5", content: "Fifth activity" },
|
||||
},
|
||||
chapters: {
|
||||
"chapter-1": { id: "chapter-1", name: "Chapter 1", activityIds: ["activity-1", "activity-2", "activity-3"] },
|
||||
"chapter-2": { id: "chapter-2", name: "Chapter 2", activityIds: ["activity-4"] },
|
||||
"chapter-3": { id: "chapter-3", name: "Chapter 3", activityIds: ["activity-5"] },
|
||||
},
|
||||
|
||||
chapterOrder: ["chapter-1", "chapter-2", "chapter-3"],
|
||||
};
|
||||
|
||||
export const initialData2 = {
|
||||
};
|
||||
|
||||
74
apps/web/components/Pages/Courses/ActivityIndicators.tsx
Normal file
74
apps/web/components/Pages/Courses/ActivityIndicators.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
|
||||
interface Props {
|
||||
course: any
|
||||
orgslug: string
|
||||
course_id: string
|
||||
current_activity?: any
|
||||
}
|
||||
|
||||
function ActivityIndicators(props: Props) {
|
||||
const course = props.course
|
||||
const orgslug = props.orgslug
|
||||
const courseid = props.course_id.replace("course_", "")
|
||||
|
||||
const done_activity_style = 'bg-teal-600 hover:bg-teal-700'
|
||||
const black_activity_style = 'bg-black hover:bg-gray-700'
|
||||
const current_activity_style = 'bg-gray-600 animate-pulse hover:bg-gray-700'
|
||||
|
||||
|
||||
function isActivityDone(activity: any) {
|
||||
if (course.trail.activities_marked_complete && course.trail.activities_marked_complete.includes(activity.id) && course.trail.status == "ongoing") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isActivityCurrent(activity: any) {
|
||||
let activityid = activity.id.replace("activity_", "")
|
||||
if (props.current_activity && props.current_activity == activityid) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getActivityClass(activity: any) {
|
||||
if (isActivityDone(activity)) {
|
||||
return done_activity_style
|
||||
}
|
||||
if (isActivityCurrent(activity)) {
|
||||
return current_activity_style
|
||||
}
|
||||
return black_activity_style
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className='grid grid-flow-col justify-stretch space-x-6'>
|
||||
{course.chapters.map((chapter: any) => {
|
||||
return (
|
||||
<>
|
||||
<div className='grid grid-flow-col justify-stretch space-x-2'>
|
||||
{chapter.activities.map((activity: any) => {
|
||||
return (
|
||||
<ToolTip sideOffset={8} slateBlack content={activity.name} key={activity.id}>
|
||||
<Link href={getUriWithOrg(orgslug, "") + `/course/${courseid}/activity/${activity.id.replace("activity_", "")}`}>
|
||||
<div className={`h-[7px] w-auto ${getActivityClass(activity)} rounded-lg shadow-md`}></div>
|
||||
|
||||
</Link>
|
||||
</ToolTip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActivityIndicators
|
||||
64
apps/web/components/Pages/Trail/TrailCourseElement.tsx
Normal file
64
apps/web/components/Pages/Trail/TrailCourseElement.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
'use client';
|
||||
import { getAPIUrl, getBackendUrl, getUriWithOrg } from '@services/config/config';
|
||||
import { removeCourse } from '@services/courses/activity';
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
|
||||
import { revalidateTags } from '@services/utils/ts/requests';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
interface TrailCourseElementProps {
|
||||
course: any
|
||||
orgslug: string
|
||||
}
|
||||
|
||||
function TrailCourseElement(props: TrailCourseElementProps) {
|
||||
const courseid = props.course.course_id.replace("course_", "")
|
||||
const course = props.course
|
||||
const router = useRouter();
|
||||
|
||||
async function quitCourse(course_id: string) {
|
||||
// Close activity
|
||||
let activity = await removeCourse(course_id, props.orgslug);
|
||||
// Mutate course
|
||||
await revalidateTags(['courses'], props.orgslug);
|
||||
router.refresh();
|
||||
|
||||
// Mutate
|
||||
mutate(`${getAPIUrl()}trail/org_slug/${props.orgslug}/trail`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='trailcoursebox flex p-3 bg-white rounded-xl' style={{ boxShadow: '0px 4px 7px 0px rgba(0, 0, 0, 0.03)' }}>
|
||||
|
||||
<Link href={getUriWithOrg(props.orgslug, "/course/" + courseid)}>
|
||||
<div className="course_tumbnail inset-0 ring-1 ring-inset ring-black/10 rounded-lg relative h-[50px] w-[72px] bg-cover bg-center" style={{ backgroundImage: `url(${getCourseThumbnailMediaDirectory(props.course.course_object.org_id, props.course.course_object.course_id, props.course.course_object.thumbnail)})`, boxShadow: '0px 4px 7px 0px rgba(0, 0, 0, 0.03)' }}></div>
|
||||
</Link>
|
||||
<div className="course_meta pl-5 flex-grow space-y-1">
|
||||
<div className="course_top">
|
||||
<div className="course_info flex">
|
||||
<div className="course_basic flex flex-col flex-end -space-y-2">
|
||||
<p className='p-0 font-bold text-sm text-gray-700'>Course</p>
|
||||
<div className="course_progress flex items-center space-x-2">
|
||||
<h2 className='font-bold text-xl'>{course.course_object.name}</h2>
|
||||
<div className='bg-slate-300 rounded-full w-[10px] h-[5px]'></div>
|
||||
<h2>{course.progress}%</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="course_actions flex-grow flex flex-row-reverse">
|
||||
<button onClick={() => quitCourse(course.course_id)} className="bg-red-200 text-red-700 hover:bg-red-300 rounded-full text-xs h-5 px-2 font-bold">Quit Course</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="course_progress indicator w-full">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 ">
|
||||
<div className={`bg-teal-600 h-1.5 rounded-full`} style={{ width: `${course.progress}%` }} ></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrailCourseElement
|
||||
79
apps/web/components/Security/AuthProvider.tsx
Normal file
79
apps/web/components/Security/AuthProvider.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import { getNewAccessTokenUsingRefreshToken, getUserInfo } from "../../services/auth/auth";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
export const AuthContext: any = React.createContext({});
|
||||
|
||||
const PRIVATE_ROUTES = ["/course/*/edit", "/settings*", "/trail"];
|
||||
const NON_AUTHENTICATED_ROUTES = ["/login", "/register"];
|
||||
|
||||
export interface Auth {
|
||||
access_token: string;
|
||||
isAuthenticated: boolean;
|
||||
userInfo: {};
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthProvider = ({ children }: any) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [auth, setAuth] = React.useState<Auth>({ access_token: "", isAuthenticated: false, userInfo: {}, isLoading: true });
|
||||
|
||||
function deleteCookie(cookieName: string) {
|
||||
document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
}
|
||||
|
||||
|
||||
async function checkRefreshToken() {
|
||||
//deleteCookie("access_token_cookie");
|
||||
let data = await getNewAccessTokenUsingRefreshToken();
|
||||
if (data) {
|
||||
return data.access_token;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
let access_token = await checkRefreshToken();
|
||||
let userInfo = {};
|
||||
let isLoading = false;
|
||||
|
||||
if (access_token) {
|
||||
userInfo = await getUserInfo(access_token);
|
||||
setAuth({ access_token, isAuthenticated: true, userInfo, isLoading });
|
||||
|
||||
// Redirect to home if user is trying to access a NON_AUTHENTICATED_ROUTES route
|
||||
|
||||
if (NON_AUTHENTICATED_ROUTES.some((route) => new RegExp(`^${route.replace("*", ".*")}$`).test(pathname))) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
setAuth({ access_token, isAuthenticated: false, userInfo, isLoading });
|
||||
|
||||
// Redirect to login if user is trying to access a private route
|
||||
if (PRIVATE_ROUTES.some((route) => new RegExp(`^${route.replace("*", ".*")}$`).test(pathname))) {
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
return () => {
|
||||
auth.isLoading = false;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
50
apps/web/components/Security/AuthenticatedClientElement.tsx
Normal file
50
apps/web/components/Security/AuthenticatedClientElement.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client';
|
||||
import React from "react";
|
||||
import { AuthContext } from "./AuthProvider";
|
||||
|
||||
interface AuthenticatedClientElementProps {
|
||||
children: React.ReactNode;
|
||||
checkMethod: 'authentication' | 'roles';
|
||||
orgId?: string;
|
||||
|
||||
}
|
||||
|
||||
export const AuthenticatedClientElement = (props: AuthenticatedClientElementProps) => {
|
||||
const auth: any = React.useContext(AuthContext);
|
||||
|
||||
// Available roles
|
||||
const org_roles_values = ["admin", "owner"];
|
||||
const user_roles_values = ["role_admin"];
|
||||
|
||||
|
||||
|
||||
function checkRoles() {
|
||||
const org_id = props.orgId;
|
||||
const org_roles = auth.userInfo.user_object.orgs;
|
||||
const user_roles = auth.userInfo.user_object.roles;
|
||||
const org_role = org_roles.find((org: any) => org.org_id == org_id);
|
||||
const user_role = user_roles.find((role: any) => role.org_id == org_id);
|
||||
|
||||
if (org_role && user_role) {
|
||||
if (org_roles_values.includes(org_role.org_role) || user_roles_values.includes(user_role.role_id)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ((props.checkMethod == 'authentication' && auth.isAuthenticated) || (auth.isAuthenticated && props.checkMethod == 'roles' && checkRoles())) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
return <></>;
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default AuthenticatedClientElement
|
||||
67
apps/web/components/Security/HeaderProfileBox.tsx
Normal file
67
apps/web/components/Security/HeaderProfileBox.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
'use client';
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
import { AuthContext } from "./AuthProvider";
|
||||
import Avvvatars from "avvvatars-react";
|
||||
import { GearIcon } from "@radix-ui/react-icons";
|
||||
|
||||
export const HeaderProfileBox = () => {
|
||||
const auth: any = React.useContext(AuthContext);
|
||||
|
||||
return (
|
||||
<ProfileArea>
|
||||
{!auth.isAuthenticated && (
|
||||
<UnidentifiedArea className="flex text-sm text-gray-700 font-bold p-1.5 px-2 rounded-lg">
|
||||
<ul className="flex space-x-3 items-center">
|
||||
<li>
|
||||
<Link href="/login">
|
||||
Login
|
||||
</Link>
|
||||
</li>
|
||||
<li className="bg-black rounded-lg shadow-md p-2 px-3 text-white">
|
||||
<Link href="/signup">
|
||||
Sign up
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</UnidentifiedArea>
|
||||
)}
|
||||
{auth.isAuthenticated && (
|
||||
<AccountArea className="-space-x-2">
|
||||
<div className="text-xs px-4 text-gray-600 p-1.5 rounded-full bg-gray-50">{auth.userInfo.user_object.full_name}</div>
|
||||
<div className="flex -space-x-2 items-center">
|
||||
<div className="py-4">
|
||||
<Avvvatars size={26} value={auth.userInfo.user_object.user_id} style="shape" />
|
||||
</div>
|
||||
<Link className="bg-gray-50 p-1.5 rounded-full" href={"/settings"}><GearIcon fontSize={26} /></Link>
|
||||
</div>
|
||||
</AccountArea>
|
||||
)}
|
||||
</ProfileArea>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountArea = styled.div`
|
||||
display: flex;
|
||||
place-items: center;
|
||||
|
||||
img {
|
||||
width: 29px;
|
||||
border-radius: 19px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProfileArea = styled.div`
|
||||
display: flex;
|
||||
place-items: stretch;
|
||||
place-items: center;
|
||||
`;
|
||||
|
||||
const UnidentifiedArea = styled.div`
|
||||
display: flex;
|
||||
place-items: stretch;
|
||||
flex-grow: 1;
|
||||
|
||||
|
||||
`;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
function NewCollectionButton() {
|
||||
return (
|
||||
<button className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased ring-offset-purple-800 p-2 px-5 my-auto font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center">
|
||||
<div>New Collection </div>
|
||||
<div className='text-md bg-neutral-800 px-1 rounded-full'>+</div>
|
||||
</button>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default NewCollectionButton
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
function NewCourseButton() {
|
||||
return (
|
||||
<button className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased ring-offset-purple-800 p-2 px-5 my-auto font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center">
|
||||
<div>New Course </div>
|
||||
<div className='text-md bg-neutral-800 px-1 rounded-full'>+</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewCourseButton
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { styled, keyframes } from '@stitches/react';
|
||||
import { blackA, } from '@radix-ui/colors';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
type ModalParams = {
|
||||
confirmationMessage: string;
|
||||
confirmationButtonText: string;
|
||||
dialogTitle: string;
|
||||
functionToExecute: any;
|
||||
dialogTrigger?: React.ReactNode;
|
||||
status?: "warning" | "info";
|
||||
};
|
||||
|
||||
const ConfirmationModal = (params: ModalParams) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
|
||||
const warningColors = 'bg-red-100 text-red-600'
|
||||
const infoColors = 'bg-blue-100 text-blue-600'
|
||||
const warningButtonColors = 'text-white bg-red-500 hover:bg-red-600'
|
||||
const infoButtonColors = 'text-white bg-blue-500 hover:bg-blue-600'
|
||||
|
||||
|
||||
const onOpenChange = React.useCallback(
|
||||
(open: any) => {
|
||||
setIsDialogOpen(open);
|
||||
},
|
||||
[setIsDialogOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isDialogOpen} onOpenChange={onOpenChange}>
|
||||
{params.dialogTrigger ? (
|
||||
<Dialog.Trigger asChild >
|
||||
{params.dialogTrigger}
|
||||
</Dialog.Trigger>
|
||||
) : null}
|
||||
|
||||
<Dialog.Portal>
|
||||
<DialogOverlay />
|
||||
<DialogContent >
|
||||
<div className='h-26 flex space-x-4 tracking-tight'>
|
||||
<div className={`icon p-6 rounded-xl flex items-center align-content-center ${params.status === 'warning' ? warningColors : infoColors}`}>
|
||||
{params.status === 'warning' ? <AlertTriangle size={35} /> : <Info size={35} />}
|
||||
</div>
|
||||
<div className="text pt-1 space-x-0 w-auto flex-grow">
|
||||
<div className="text-xl font-bold text-black ">
|
||||
{params.dialogTitle}
|
||||
</div>
|
||||
<div className="text-md text-gray-500 w-60 leading-tight">
|
||||
{params.confirmationMessage}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse pt-2">
|
||||
<div className={`rounded-md text-sm px-3 py-2 font-bold flex justify-center items-center hover:cursor-pointer ${params.status === 'warning' ? warningButtonColors : infoButtonColors}
|
||||
hover:shadow-lg transition duration-300 ease-in-out
|
||||
`}
|
||||
onClick={() => { params.functionToExecute(); setIsDialogOpen(false) }}>
|
||||
{params.confirmationButtonText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
};
|
||||
|
||||
const overlayShow = keyframes({
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
});
|
||||
|
||||
const overlayClose = keyframes({
|
||||
'0%': { opacity: 1 },
|
||||
'100%': { opacity: 0 },
|
||||
});
|
||||
|
||||
const contentShow = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translate(-50%, -50%) scale(.96)' },
|
||||
'100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
|
||||
});
|
||||
|
||||
const contentClose = keyframes({
|
||||
'0%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
|
||||
'100%': { opacity: 0, transform: 'translate(-50%, -52%) scale(.96)' },
|
||||
});
|
||||
|
||||
const DialogOverlay = styled(Dialog.Overlay, {
|
||||
backgroundColor: blackA.blackA9,
|
||||
position: 'fixed',
|
||||
zIndex: 500,
|
||||
inset: 0,
|
||||
animation: `${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${overlayClose} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
});
|
||||
|
||||
const DialogContent = styled(Dialog.Content, {
|
||||
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 18,
|
||||
zIndex: 501,
|
||||
boxShadow: 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 'auto',
|
||||
minWidth: '500px',
|
||||
overflow: 'hidden',
|
||||
height: 'auto',
|
||||
maxHeight: '85vh',
|
||||
maxWidth: '600px',
|
||||
padding: 11,
|
||||
animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
'&:focus': { outline: 'none' },
|
||||
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${contentClose} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
transition: "max-height 0.3s ease-out",
|
||||
});
|
||||
|
||||
|
||||
export default ConfirmationModal;
|
||||
23
apps/web/components/StyledElements/Error/Error.tsx
Normal file
23
apps/web/components/StyledElements/Error/Error.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react'
|
||||
|
||||
function ErrorUI() {
|
||||
return (
|
||||
<div className='flex items-center justify-center h-screen'>
|
||||
<div className='mx-auto bg-red-100 w-[800px] p-3 rounded-xl m-5 '>
|
||||
<div className='flex flex-row'>
|
||||
<div className='p-3 pr-4' >
|
||||
<svg width="35" height="35" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 2H5C4.20435 2 3.44129 2.31607 2.87868 2.87868C2.31607 3.44129 2 4.20435 2 5V15C2 15.7956 2.31607 16.5587 2.87868 17.1213C3.44129 17.6839 4.20435 18 5 18H16.59L20.29 21.71C20.3834 21.8027 20.4943 21.876 20.6161 21.9258C20.7379 21.9755 20.8684 22.0008 21 22C21.1312 22.0034 21.2613 21.976 21.38 21.92C21.5626 21.845 21.7189 21.7176 21.8293 21.5539C21.9396 21.3901 21.999 21.1974 22 21V5C22 4.20435 21.6839 3.44129 21.1213 2.87868C20.5587 2.31607 19.7956 2 19 2ZM20 18.59L17.71 16.29C17.6166 16.1973 17.5057 16.124 17.3839 16.0742C17.2621 16.0245 17.1316 15.9992 17 16H5C4.73478 16 4.48043 15.8946 4.29289 15.7071C4.10536 15.5196 4 15.2652 4 15V5C4 4.73478 4.10536 4.48043 4.29289 4.29289C4.48043 4.10536 4.73478 4 5 4H19C19.2652 4 19.5196 4.10536 19.7071 4.29289C19.8946 4.48043 20 4.73478 20 5V18.59ZM12 12C11.8022 12 11.6089 12.0586 11.4444 12.1685C11.28 12.2784 11.1518 12.4346 11.0761 12.6173C11.0004 12.8 10.9806 13.0011 11.0192 13.1951C11.0578 13.3891 11.153 13.5673 11.2929 13.7071C11.4327 13.847 11.6109 13.9422 11.8049 13.9808C11.9989 14.0194 12.2 13.9996 12.3827 13.9239C12.5654 13.8482 12.7216 13.72 12.8315 13.5556C12.9414 13.3911 13 13.1978 13 13C13 12.7348 12.8946 12.4804 12.7071 12.2929C12.5196 12.1054 12.2652 12 12 12ZM12 6C11.7348 6 11.4804 6.10536 11.2929 6.29289C11.1054 6.48043 11 6.73478 11 7V10C11 10.2652 11.1054 10.5196 11.2929 10.7071C11.4804 10.8946 11.7348 11 12 11C12.2652 11 12.5196 10.8946 12.7071 10.7071C12.8946 10.5196 13 10.2652 13 10V7C13 6.73478 12.8946 6.48043 12.7071 6.29289C12.5196 6.10536 12.2652 6 12 6Z" fill="#CC0505" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='p-3 '>
|
||||
<h1 className='text-2xl font-bold text-red-600'>Error</h1>
|
||||
<p className='pt-0 text-md text-red-600'>Something went wrong</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorUI
|
||||
104
apps/web/components/StyledElements/Form/Form.tsx
Normal file
104
apps/web/components/StyledElements/Form/Form.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React from 'react';
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { styled, keyframes } from '@stitches/react';
|
||||
import { blackA, violet, mauve } from '@radix-ui/colors';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
const FormLayout = (props: any, onSubmit: any) => (
|
||||
<FormRoot onSubmit={props.onSubmit}>
|
||||
{props.children}
|
||||
</FormRoot>
|
||||
);
|
||||
|
||||
export const FormLabelAndMessage = (props: { label: string, message?: string }) => (
|
||||
<div className='flex items-center space-x-3'>
|
||||
<FormLabel className='grow'>{props.label}</FormLabel>
|
||||
{props.message && <div className='text-red-700 text-sm items-center rounded-md flex space-x-1'><Info size={10} /><div>{props.message}</div></div> || <></>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FormRoot = styled(Form.Root, {
|
||||
margin: 7
|
||||
});
|
||||
|
||||
export const FormField = styled(Form.Field, {
|
||||
display: 'grid',
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
export const FormLabel = styled(Form.Label, {
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
lineHeight: '35px',
|
||||
color: 'black',
|
||||
});
|
||||
|
||||
export const FormMessage = styled(Form.Message, {
|
||||
fontSize: 13,
|
||||
color: 'white',
|
||||
opacity: 0.8,
|
||||
});
|
||||
|
||||
export const Flex = styled('div', { display: 'flex' });
|
||||
|
||||
export const inputStyles = {
|
||||
all: 'unset',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
color: '#7c7c7c',
|
||||
background: "#F9FAFB",
|
||||
boxShadow: `0 0 0 1px #edeeef`,
|
||||
'&:hover': { boxShadow: `0 0 0 1px #edeeef` },
|
||||
'&:focus': { boxShadow: `0 0 0 2px #edeeef` },
|
||||
'&::selection': { backgroundColor: blackA.blackA9, color: 'white' },
|
||||
|
||||
};
|
||||
|
||||
export const Input = styled('input', {
|
||||
...inputStyles,
|
||||
height: 35,
|
||||
lineHeight: 1,
|
||||
padding: '0 10px',
|
||||
border: 'none',
|
||||
});
|
||||
|
||||
export const Textarea = styled('textarea', {
|
||||
...inputStyles,
|
||||
resize: 'none',
|
||||
padding: 10,
|
||||
});
|
||||
|
||||
export const ButtonBlack = styled('button', {
|
||||
variants: {
|
||||
state: {
|
||||
"loading": {
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: "#808080",
|
||||
},
|
||||
"none": {
|
||||
},
|
||||
},
|
||||
},
|
||||
all: 'unset',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
padding: '0 15px',
|
||||
fontSize: 15,
|
||||
lineHeight: 1,
|
||||
fontWeight: 500,
|
||||
height: 35,
|
||||
|
||||
background: "#000000",
|
||||
color: "#FFFFFF",
|
||||
'&:hover': { backgroundColor: "#181818", cursor: "pointer" },
|
||||
'&:focus': { boxShadow: `0 0 0 2px black` },
|
||||
});
|
||||
|
||||
export default FormLayout;
|
||||
159
apps/web/components/StyledElements/Modal/Modal.tsx
Normal file
159
apps/web/components/StyledElements/Modal/Modal.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { styled, keyframes } from '@stitches/react';
|
||||
import { violet, blackA, mauve, green } from '@radix-ui/colors';
|
||||
import { ButtonBlack } from '../Form/Form';
|
||||
|
||||
type ModalParams = {
|
||||
dialogTitle: string;
|
||||
dialogDescription: string;
|
||||
dialogContent: React.ReactNode;
|
||||
dialogClose?: React.ReactNode | null;
|
||||
dialogTrigger?: React.ReactNode;
|
||||
addDefCloseButton?: boolean;
|
||||
onOpenChange: any;
|
||||
isDialogOpen?: boolean;
|
||||
minHeight?: "sm" | "md" | "lg" | "xl" | "no-min";
|
||||
};
|
||||
|
||||
const Modal = (params: ModalParams) => (
|
||||
<Dialog.Root open={params.isDialogOpen} onOpenChange={params.onOpenChange}>
|
||||
{params.dialogTrigger ? (
|
||||
<Dialog.Trigger asChild>
|
||||
{params.dialogTrigger}
|
||||
</Dialog.Trigger>
|
||||
) : null}
|
||||
|
||||
<Dialog.Portal>
|
||||
<DialogOverlay />
|
||||
<DialogContent minHeight={params.minHeight}>
|
||||
<DialogTopBar className='-space-y-1'>
|
||||
<DialogTitle>{params.dialogTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{params.dialogDescription}
|
||||
</DialogDescription>
|
||||
</DialogTopBar>
|
||||
{params.dialogContent}
|
||||
{params.dialogClose ? (
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Dialog.Close asChild>
|
||||
{params.dialogClose}
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
) : null}
|
||||
{params.addDefCloseButton ? (
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Dialog.Close asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>Close</ButtonBlack>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
</DialogContent>
|
||||
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
|
||||
const overlayShow = keyframes({
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
});
|
||||
|
||||
const overlayClose = keyframes({
|
||||
'0%': { opacity: 1 },
|
||||
'100%': { opacity: 0 },
|
||||
});
|
||||
|
||||
const contentShow = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translate(-50%, -50%) scale(.96)' },
|
||||
'100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
|
||||
});
|
||||
|
||||
const contentClose = keyframes({
|
||||
'0%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
|
||||
'100%': { opacity: 0, transform: 'translate(-50%, -52%) scale(.96)' },
|
||||
});
|
||||
|
||||
const DialogOverlay = styled(Dialog.Overlay, {
|
||||
backgroundColor: blackA.blackA9,
|
||||
position: 'fixed',
|
||||
zIndex: 500,
|
||||
inset: 0,
|
||||
animation: `${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${overlayClose} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
});
|
||||
|
||||
const DialogContent = styled(Dialog.Content, {
|
||||
|
||||
variants: {
|
||||
minHeight: {
|
||||
'no-min': {
|
||||
minHeight: '0px',
|
||||
},
|
||||
'sm': {
|
||||
minHeight: '300px',
|
||||
},
|
||||
'md': {
|
||||
minHeight: '500px',
|
||||
},
|
||||
'lg': {
|
||||
minHeight: '700px',
|
||||
},
|
||||
'xl': {
|
||||
minHeight: '900px',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 18,
|
||||
zIndex: 501,
|
||||
boxShadow: 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '90vw',
|
||||
overflow: 'hidden',
|
||||
maxHeight: '85vh',
|
||||
minHeight: '300px',
|
||||
maxWidth: '600px',
|
||||
padding: 11,
|
||||
animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
'&:focus': { outline: 'none' },
|
||||
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${contentClose} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
transition: "max-height 0.3s ease-out",
|
||||
});
|
||||
|
||||
const DialogTopBar = styled('div', {
|
||||
background: "#F7F7F7",
|
||||
padding: "8px 14px ",
|
||||
borderRadius: 14,
|
||||
});
|
||||
const DialogTitle = styled(Dialog.Title, {
|
||||
margin: 0,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.05em",
|
||||
padding: 0,
|
||||
color: mauve.mauve12,
|
||||
fontSize: 21,
|
||||
});
|
||||
|
||||
const DialogDescription = styled(Dialog.Description, {
|
||||
color: mauve.mauve11,
|
||||
letterSpacing: "-0.03em",
|
||||
fontSize: 15,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
const Flex = styled('div', { display: 'flex' });
|
||||
|
||||
export default Modal;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import Image from 'next/image'
|
||||
import CoursesLogo from "public/svg/courses.svg";
|
||||
import CollectionsLogo from "public/svg/collections.svg";
|
||||
import TrailLogo from "public/svg/trail.svg";
|
||||
|
||||
function TypeOfContentTitle(props: { title: string, type: string }) {
|
||||
|
||||
function getLogo() {
|
||||
if (props.type == "col") {
|
||||
return CollectionsLogo;
|
||||
}
|
||||
|
||||
else if (props.type == "cou") {
|
||||
return CoursesLogo;
|
||||
}
|
||||
|
||||
else if (props.type == "tra") {
|
||||
return TrailLogo;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home_category_title flex my-5 items-center">
|
||||
<div className="ml-2 rounded-full ring-1 ring-slate-900/5 shadow-inner p-2 my-auto mr-4">
|
||||
<Image className="" src={getLogo()} alt="Courses logo" />
|
||||
</div>
|
||||
<h1 className="font-bold text-2xl">{props.title}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TypeOfContentTitle
|
||||
12
apps/web/components/StyledElements/Toast/Toast.tsx
Normal file
12
apps/web/components/StyledElements/Toast/Toast.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
'use client';
|
||||
import React from 'react'
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
|
||||
function Toast() {
|
||||
return (
|
||||
<><Toaster /></>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast
|
||||
98
apps/web/components/StyledElements/Tooltip/Tooltip.tsx
Normal file
98
apps/web/components/StyledElements/Tooltip/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { styled, keyframes } from '@stitches/react';
|
||||
|
||||
|
||||
type TooltipProps = {
|
||||
sideOffset?: number;
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
slateBlack?: boolean;
|
||||
};
|
||||
|
||||
const ToolTip = (props: TooltipProps) => {
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
{props.children}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<TooltipContent slateBlack={props.slateBlack} side="bottom" sideOffset={props.sideOffset}>
|
||||
{props.content}
|
||||
</TooltipContent>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const slideUpAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateY(2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||
});
|
||||
|
||||
const slideRightAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateX(-2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateX(0)' },
|
||||
});
|
||||
|
||||
const slideDownAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateY(-2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||
});
|
||||
|
||||
const slideLeftAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateX(2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateX(0)' },
|
||||
});
|
||||
|
||||
const closeAndFade = keyframes({
|
||||
'0%': { opacity: 1 },
|
||||
'100%': { opacity: 0 },
|
||||
});
|
||||
|
||||
const TooltipContent = styled(Tooltip.Content, {
|
||||
|
||||
variants: {
|
||||
slateBlack: {
|
||||
true: {
|
||||
backgroundColor: " #0d0d0d",
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
borderRadius: 4,
|
||||
padding: '5px 10px',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
color: "black",
|
||||
backgroundColor: 'rgba(217, 217, 217, 0.50)',
|
||||
zIndex: 500,
|
||||
boxShadow: 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
userSelect: 'none',
|
||||
animationDuration: '400ms',
|
||||
animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
willChange: 'transform, opacity',
|
||||
'&[data-state="delayed-open"]': {
|
||||
'&[data-side="top"]': { animationName: slideDownAndFade },
|
||||
'&[data-side="right"]': { animationName: slideLeftAndFade },
|
||||
'&[data-side="bottom"]': { animationName: slideUpAndFade },
|
||||
'&[data-side="left"]': { animationName: slideRightAndFade },
|
||||
},
|
||||
|
||||
// closing animation
|
||||
'&[data-state="closed"]': {
|
||||
'&[data-side="top"]': { animationName: closeAndFade },
|
||||
'&[data-side="right"]': { animationName: closeAndFade },
|
||||
'&[data-side="bottom"]': { animationName: closeAndFade },
|
||||
'&[data-side="left"]': { animationName: closeAndFade },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
export default ToolTip;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
function GeneralWrapperStyled({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className='max-w-screen-2xl mx-auto px-16 py-5 tracking-tight z-50'
|
||||
|
||||
>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeneralWrapperStyled
|
||||
13
apps/web/components/Utils/ClientComp.tsx
Normal file
13
apps/web/components/Utils/ClientComp.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
function ClientComponentSkeleton({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClientComponentSkeleton
|
||||
30
apps/web/components/Utils/libs/styled-registry.tsx
Normal file
30
apps/web/components/Utils/libs/styled-registry.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useServerInsertedHTML } from 'next/navigation';
|
||||
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
|
||||
|
||||
export default function StyledComponentsRegistry({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Only create stylesheet once with lazy initial state
|
||||
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
|
||||
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
|
||||
|
||||
useServerInsertedHTML(() => {
|
||||
const styles = styledComponentsStyleSheet.getStyleElement();
|
||||
styledComponentsStyleSheet.instance.clearTag();
|
||||
return <>{styles}</>;
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') return <>{children}</>;
|
||||
|
||||
return (
|
||||
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
|
||||
{children as React.ReactChild}
|
||||
</StyleSheetManager>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue