feat: add custom navigation link to org landing page (#569)

* feat: custom navbar UI complete

* chore: added translations

* fear: attending to reviews

* add orgname to org landing page

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
Nwosu Emmanuel
2025-10-09 01:13:06 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 9a7018eb34
commit 27ee51155e
48 changed files with 2845 additions and 106 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
npm-debug.log
+20
View File
@@ -0,0 +1,20 @@
## Supabase - DB
PUBLIC_SUPABASE_ANON_KEY=
PUBLIC_SUPABASE_URL=
## Cloudflare - Video uploads
# Not required - https://cdn.example.com
CLOUDFLARE_BUCKET_DOMAIN=
CLOUDFLARE_ACCESS_KEY=
CLOUDFLARE_SECRET_ACCESS_KEY=
CLOUDFLARE_ACCOUNT_ID=
# Email
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
SMTP_PORT=
# Sentry DNS
SENTRY_DNS=
+71
View File
@@ -0,0 +1,71 @@
FROM node:slim AS app
# Add build arguments
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_SUPABASE_URL
ARG CLOUDFLARE_BUCKET_DOMAIN
ARG CLOUDFLARE_ACCESS_KEY
ARG CLOUDFLARE_SECRET_ACCESS_KEY
ARG CLOUDFLARE_ACCOUNT_ID
ARG CLOUDFLARE_BUCKET_ID
ARG SMTP_HOST
ARG SMTP_USER
ARG SMTP_PASSWORD
ARG SMTP_PORT
ARG SMTP_SENDER
RUN mkdir /app
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Install chrome stable from sources, then remove it. Why? The
# npm install of `puppeteer` brings its own bundle chromium build,
# and puppeteer releases are only guaranteed to work with that version.
#
# But the bundled chromium implicitly needs a bunch of shared libs on
# the host. It's a little tedious to find and maintain that list; but
# the official apt distro of `google-chrome-stable` should bring the right
# set along. So do that, but immediately uninstall (to free up layer space).
#
# This is a little brittle, since the puppeteer chrome could in theory diverge
# from the official apt chrome's shared lib deps. But it works..
# RUN apt-get update \
# && apt-get install curl gnupg -y \
# && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
# && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
# && apt-get update \
# && apt-get install google-chrome-stable -y --no-install-recommends \
# && apt-get remove google-chrome-stable -y \
# && rm -rf /var/lib/apt/lists/*
# Add emoji fonts.
# Source: https://gist.github.com/win0err/9d8c7f0feabdfe8a4c9787b02c79ac51
# RUN mkdir ~/.fonts/ && \
# wget https://github.com/samuelngs/apple-emoji-linux/releases/download/ios-15.4/AppleColorEmoji.ttf -O ~/.fonts/AppleColorEmoji.ttf
COPY ./package.json /app
RUN pnpm install
COPY . /app
# Set environment variables from build args
ENV NODE_ENV=production \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
CLOUDFLARE_BUCKET_DOMAIN=$CLOUDFLARE_BUCKET_DOMAIN \
CLOUDFLARE_ACCESS_KEY=$CLOUDFLARE_ACCESS_KEY \
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
SMTP_HOST=$SMTP_HOST \
SMTP_USER=$SMTP_USER \
SMTP_PASSWORD=$SMTP_PASSWORD \
SMTP_PORT=$SMTP_PORT \
PORT=3002
RUN pnpm build
EXPOSE 3002
CMD ["pnpm", "start"]
+431
View File
@@ -0,0 +1,431 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<title>Certificate</title>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
body {
position: relative;
}
</style>
</head>
<body>
<img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute top-0 left-0 w-full h-[100vh] z-10"
/>
<div class="absolute z-20 left-[5%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="flex border justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div>
<!-- plain blueProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/blue-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- plain purpleProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/purple-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-fuchsia-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!------------------------------------------------------- new secton ---------------------------------------------------- -->
<!-- PurpleProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/purple-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-[#95449A]">Usman Elebute Ayomide</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- BlueProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div> -->
<!-- --------------------- old certificates ------------------------- -->
<!-- <div
class="flex items-center justify-center border border-dashed border-blue-400 h-full w-full"
>
<div class="h-full flex border-l-8 border-blue-800">
<div class="h-full w-full px-5 py-10">
<p class="text-2xl font-normal my-2 mb-10 uppercase text-gray-500">
Certificate<br />
of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-9xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">
has successfully completed introductory courses in
</p>
<div>
<p
class="bg-transparent font-semibold text-blue-800 text-center uppercase my-10 text-3xl"
>
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl font-light mb-4 text-justify">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<div class="flex items-center gap-1 mt-14">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
</div>
</div>
</div> -->
<!-- <div
class="flex flex-col items-center justify-center border-4 border-pink-700 py-3 w-full h-full"
>
<div class="flex items-center gap-1 my-2 mb-10">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
<div class="w-full px-5 py-3">
<p
class="text-xl text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full"
>
Certificate of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-8xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">has successfully completed a courses in</p>
<div class="my-10">
<p class="bg-transparent font-semibold text-pink-700 text-center uppercase text-3xl">
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl text-center text-gray-500 font-light">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
</div> -->
</body>
</html>
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="flex justify-center items-center flex-col border-[50px] mx-10 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
# fly.toml app configuration file generated for classroomio-server on 2023-07-27T21:01:09+01:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "classroomio-server"
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3002
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
+186
View File
@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
background-image: url("https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/course/course-pdf-background.jpg");
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="border-l-[50px] mb-[15%] h-[100vh] m-0 relative">
<h1 class="font-bold text-8xl text-black m-0 w-3/4 absolute left-5 top-40">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black m-0 absolute left-10 bottom-24">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
PUBLIC_SUPABASE_URL: z.string(),
PUBLIC_SUPABASE_ANON_KEY: z.string(),
ZOHO_TOKEN: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_SENDER: z.string().optional()
});
export const env = envSchema.parse(process.env);
+25
View File
@@ -0,0 +1,25 @@
import { env } from '$src/config/env';
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
CLOUDFLARE_SECRET_ACCESS_KEY,
CLOUDFLARE_ACCOUNT_ID
} = env;
export const CLOUDFLARE = {
CONFIGS: {
BUCKET_DOMAIN: CLOUDFLARE_BUCKET_DOMAIN!,
ACCESS_KEY: CLOUDFLARE_ACCESS_KEY!,
SECRET_ACCESS_KEY: CLOUDFLARE_SECRET_ACCESS_KEY!,
ACCOUNT_ID: CLOUDFLARE_ACCOUNT_ID!
},
R2: {
BUCKET: 'videos',
BUCKET_DOMAIN: CLOUDFLARE_ACCOUNT_ID
? `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
: CLOUDFLARE_BUCKET_DOMAIN!,
PRESIGN_EXPIRATION_TIME: 60 * 60, // 1 hour
DOWNLOAD_EXPIRATION_TIME: 60 * 60 * 3 // 3 hours
}
} as const;
+44
View File
@@ -0,0 +1,44 @@
import { Context, Next } from 'hono';
import { validateUser } from '$src/utils/auth/validate-user';
export const authMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('authorization');
if (!authHeader) {
return c.json(
{
success: false,
message: 'No authorization header provided'
},
401
);
}
// Extract the token from "Bearer <token>"
const token = authHeader.split(' ')[1];
if (!token) {
return c.json(
{
success: false,
message: 'Invalid authorization header format'
},
401
);
}
const user = await validateUser(token);
c.set('user', user); // Attach the user to the context
await next();
} catch (error) {
return c.json(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
+41
View File
@@ -0,0 +1,41 @@
import { Context, Hono } from 'hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono();
katexRouter.get('/', async (c: Context) => {
try {
const original = c.req.raw.url;
const latexString = original.split('?')[1].replaceAll('&plus;', '+').replaceAll('&space;', '');
const html = katex.renderToString(latexString, {
output: 'mathml',
throwOnError: false
});
return c.html(html);
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error rendering LaTeX:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
500
);
}
});
+70
View File
@@ -0,0 +1,70 @@
import { Context, Hono } from 'hono';
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
import { ZSendEmailValidation } from '$src/types/mail';
import { env } from '$src/config/env';
import { z } from 'zod';
export const mailRouter = new Hono();
mailRouter.post('/send', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZSendEmailValidation.parse(data);
const results = await Promise.all(
validatedData.map(async (emailData) => {
try {
const res = env.ZOHO_TOKEN
? await sendWithZoho(emailData)
: await sendWithNodemailer(emailData);
console.log('Email status:', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
details: results
},
500
);
}
return c.json({ success: true, details: results });
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error sending emails:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
500
);
}
});
+97
View File
@@ -0,0 +1,97 @@
import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail';
import { nodemailerTransporter, zohoClient } from '$src/utils/email';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '$src/types/mail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
export const mailRouter = new Hono();
interface EmailResponse {
success: boolean;
error?: string;
details?: unknown;
}
let transporter: Transporter | null;
nodemailerTransporter().then((t: Transporter | null) => {
transporter = t;
});
export async function sendWithNodemailer(
emailData: TSendEmailValidation[0]
): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
return {
success: false,
error: 'Email transporter not initialized'
};
}
try {
const result = await transporter.sendMail({
from: from || env.SMTP_SENDER,
to,
subject,
replyTo,
html: withEmailTemplate(content)
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
const { from, to, subject, content } = emailData;
const fromData = extractNameAndEmail(from || '');
if (!fromData) {
return {
success: false,
error: 'Invalid from data'
};
}
try {
const result = await zohoClient.sendMail({
from: {
address: fromData.email,
name: fromData.name
},
to: [
{
email_address: {
address: to
}
}
],
subject,
htmlbody: content
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
+39
View File
@@ -0,0 +1,39 @@
import { ALLOWED_CONTENT_TYPES, MAX_FILE_SIZE } from '$src/constants/upload';
import { z } from 'zod';
export * from './lesson';
export const ZCourseDownloadContent = z.object({
courseTitle: z.string(),
orgName: z.string(),
orgTheme: z.string(),
lessons: z.array(
z.object({
lessonTitle: z.string(),
lessonNumber: z.string(),
lessonNote: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional()
})
)
});
export type TCourseDownloadContent = z.infer<typeof ZCourseDownloadContent>;
export const ZCoursePresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_CONTENT_TYPES, {
errorMap: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
})
})
});
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const ZLessonDownloadContent = z.object({
title: z.string(),
number: z.string(),
orgName: z.string(),
note: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional(),
courseTitle: z.string()
});
export type TLessonDownloadContent = z.infer<typeof ZLessonDownloadContent>;
export const ZCertificateDownload = z.object({
theme: z.string().min(1),
studentName: z.string().min(1),
courseName: z.string().min(1),
courseDescription: z.string().min(1),
orgName: z.string().min(1),
orgLogoUrl: z.string().url().optional(),
facilitator: z.string().optional()
});
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const ZSendEmailValidation = z.array(
z.object({
from: z.string().optional(),
to: z.string().email(),
subject: z.string().min(1),
content: z.string().min(1),
replyTo: z.string().email().optional()
})
);
export type TSendEmailValidation = z.infer<typeof ZSendEmailValidation>;
@@ -0,0 +1,21 @@
import { User } from '@supabase/supabase-js';
import { getSupabase } from '../supabase.js';
const supabase = getSupabase();
export async function validateUser(accessToken: string): Promise<User> {
let user: User | null = null;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
throw new Error('Unauthenticated user');
}
return user;
}
+124
View File
@@ -0,0 +1,124 @@
import type { TCertificateDownload } from '$src/types/course/lesson';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
export interface CertificateData {
theme: string;
studentName: string;
courseName: string;
courseDescription: string;
orgLogoUrl: string;
orgName: string;
facilitator?: string;
}
export interface CertificateGenerationResult {
success: boolean;
buffer?: Buffer;
error?: string;
}
export const generateCertificate = async (data: TCertificateDownload) => {
const { html, styles } = generateCertificateHtml(data);
return getCloudflarePdfBuffer(html, styles);
};
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
const html = `
<html>
<head>
<meta charset="utf-8">
<title>Certificate of Completion</title>
</head>
<body>
<div class="certificate">
<div class="header">
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
<h1 class="title">Certificate of Completion</h1>
</div>
<div class="content">
<h2 class="student-name">${studentName}</h2>
<p class="course-name">${courseName}</p>
<p class="description">${courseDescription}</p>
</div>
<div class="footer">
<div class="signature">
<p>${facilitator || 'Course Facilitator'}</p>
<p>${orgName}</p>
</div>
<p class="date">${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>
`;
const styles = `
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.certificate {
width: 1123px;
height: 794px;
margin: 0 auto;
background-color: white;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding: 20px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.title {
font-size: 36px;
color: #333;
margin-bottom: 10px;
}
.content {
text-align: center;
padding: 40px;
}
.student-name {
font-size: 48px;
color: #2c3e50;
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #34495e;
margin-bottom: 10px;
}
.description {
font-size: 18px;
color: #7f8c8d;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 40px;
width: 100%;
text-align: center;
}
.signature {
margin-top: 20px;
}
.date {
margin-top: 10px;
color: #95a5a6;
}
`;
const pdf = {
html: html,
styles: styles
};
return pdf;
}
+27
View File
@@ -0,0 +1,27 @@
import { CLOUDFLARE } from '$src/constants';
export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
console.log('Generating PDF with Cloudflare API...');
try {
const pdfResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/pdf`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.ACCESS_KEY}`
},
body: JSON.stringify({
html: html,
addStyleTag: [{ content: `${styles}` }]
})
}
);
console.log('PDF response status:', pdfResponse.status);
return pdfResponse;
} catch (error) {
console.error('Error generating PDF:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
}
};
+170
View File
@@ -0,0 +1,170 @@
import type { TCourseDownloadContent } from '$src/types/course';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full break-after-page">
${body}
</body>
</html>
`;
}
type TLessonBody = TCourseDownloadContent['lessons'][0];
function getLessonBody(
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody,
orgName: string,
orgTheme: string
): string {
const noteHtml = marked.parse(lessonNote);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div
class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${orgTheme ? orgTheme : '#0030FF'}] bg-white text-md font-bold tracking-tighter">
${lessonNumber}
</h3>
<h1 class="text-white m-0">${lessonTitle}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</div>
<main class="break-after-page">
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
`;
}
function getCourseBody({
courseTitle,
orgName,
orgTheme,
lessons
}: TCourseDownloadContent): string {
return `
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
</header>
<div class="root mx-auto mt-5 prose break-after-page">
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
throw 'Download disabled';
}
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
const body = getCourseBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme);
}
+35
View File
@@ -0,0 +1,35 @@
import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
import nodemailer from 'nodemailer';
export const zohoClient = new SendMailClient({
url: 'api.zeptomail.eu/',
token: env.ZOHO_TOKEN!
});
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
console.error('SMTP configuration missing');
return null;
}
try {
const transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '465', 10),
secure: true,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
});
await transporter.verify();
return transporter;
} catch (error) {
console.error('Transporter error:', error);
return null;
}
};
+3
View File
@@ -0,0 +1,3 @@
export default function genUniqueId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
+161
View File
@@ -0,0 +1,161 @@
import type { TLessonDownloadContent } from '$src/types/course/lesson';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
background-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
${body}
</body>
</html>
`;
}
function getLessonBody({
title,
number,
orgName,
note,
slideUrl,
video,
courseTitle
}: TLessonDownloadContent): string {
const noteHtml = marked.parse(note);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div class="root mx-auto mt-5 prose">
<header
class="w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
${number}
</h3>
<h1 class="text-white m-0">${title}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</header>
<main>
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string
): Promise<Buffer> {
throw 'Download disabled';
}
export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
const body = getLessonBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle);
}
+213
View File
@@ -0,0 +1,213 @@
interface FromData {
name: string;
email: string;
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <notify@mail.classroomio.com>
export function extractNameAndEmail(str: string): FromData | undefined {
// Use regular expressions to match the name and email
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
const match = str.match(regex);
if (match) {
// Extract the name and email from the match groups
const name = match[1];
const email = match[2] + '@' + match[3];
return { name, email };
} else {
// Return undefined if the format doesn't match
return { name: str, email: str };
}
}
export const withEmailTemplate = (content: string): string =>
`<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<base target="_blank" />
<style>
body {
background-color: #F6F9FF;
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
sans-serif;
font-size: 15px;
font-weight: 400;
line-height: 26px;
margin: 0;
}
pre {
background: #f4f4f4;
padding: 2px;
}
hr {
border-top: 1px dashed #94a3b8;
margin-top: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
}
table td {
padding: 5px 10px 0px;
vertical-align: middle;
}
.socialicons {
background-color: #fff;
border-radius: 5px;
padding: 5px;
width: initial;
margin: 0 auto;
max-width: 200px;
}
.brandcolor {
color: #00c4b8;
}
.tooltip {
background-color: #f1f5f9;
padding: 1rem;
border-radius: 1rem;
color: #475569;
margin-top: 15px;
margin-bottom: 15px;
}
.tooltip a {
color: #1e293b;
}
.button {
margin-top: 12px;
background: #0233BD;
border-radius: 8px;
text-decoration: none !important;
color: #fff !important;
font-weight: 500;
padding: 10px 30px;
display: inline-block;
font-size: 0.9em;
}
.button:hover {
background: #0233BD;
}
.footer {
text-align: center;
font-size: 12px;
color: #cbd5e1;
}
.footer a {
color: #cbd5e1;
}
.gutter {
padding: 5px 30px;
text-align: center;
background-color: #fff;
border-bottom: 1px solid rgb(221, 208, 208);
max-width: 525px;
margin: 0 auto;
}
img {
max-width: 100%;
height: auto;
}
.gutter img {
max-width: 280px;
}
h1,
h2,
h3,
h4 {
font-weight: 600;
}
@media screen and (max-width: 600px) {
.wrap {
max-width: auto;
}
.gutter {
padding: 10px;
}
}
</style>
</head>
<body style="
background-color: #F6F9FF;
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
sans-serif;
font-size: 15px;
line-height: 26px;
padding: 20px 0;
margin: 0;
color: #1e293b;
">
<div class="gutter">
<a href="https://classroomio.com" target="_blank">
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO Logo" /></a>
</div>
<div class="wrap" style="
background-color: #fff;
padding: 40px 30px;
max-width: 525px;
margin: 0 auto;
">
${content}
</div>
<div class="footer" style="
text-align: center;
font-size: 12px;
color: #fff;
padding: 30px;
background-color: #0542CC;
max-width: 525px;
margin: 0 auto;">
<p>Find us on</p>
<table class="socialicons">
<tr>
<td>
<a target="_blank" href="https://twitter.com/classroomio"><img title="X"
src="https://brand.cdn.clsrio.com/socials/twitter.png" alt="X" width="19" /></a>
</td>
<td>
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"><img title="Youtube"
src="https://brand.cdn.clsrio.com/socials/youtube.png" alt="Youtube" width="24" /></a>
</td>
<td>
<a target="_blank" href="https://www.facebook.com/classroomiohq"><img title="Facebook"
src="https://brand.cdn.clsrio.com/socials/facebook.png" alt="Facebook" width="24" /></a>
</td>
</tr>
</table>
<p style="padding-top: 8px; line-height: initial">
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
target="_blank">Terms</a>
|
<a style="text-decoration: none"
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
Policy</a>
</p>
</div>
</body>
</html>
`;
+56
View File
@@ -0,0 +1,56 @@
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
GetObjectCommand,
GetObjectCommandInput,
PutObjectCommand,
PutObjectCommandInput,
S3Client
} from '@aws-sdk/client-s3';
import { CLOUDFLARE } from '$src/constants';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
export const s3Client = new S3Client({
region: 'auto',
endpoint: `https://${CLOUDFLARE.CONFIGS.ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: CLOUDFLARE.CONFIGS.ACCESS_KEY,
secretAccessKey: CLOUDFLARE.CONFIGS.SECRET_ACCESS_KEY
}
});
export async function uploadToS3(
params: PutObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function getFromS3(
params: GetObjectCommandInput
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const data = await s3Client.send(new GetObjectCommand(params));
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function deleteFromS3(
params: DeleteObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new DeleteObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
+36
View File
@@ -0,0 +1,36 @@
import { SupabaseClient, createClient } from '@supabase/supabase-js';
import { env } from '$src/config/env';
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
let supabase: SupabaseClient | null = null;
interface SupabaseConfig {
supabaseConfig: {
url: string;
anonKey: string;
};
}
export function getSupabase(config?: SupabaseConfig): SupabaseClient {
if (supabase) return supabase;
const finalConfig = config ?? {
supabaseConfig: {
url: PUBLIC_SUPABASE_URL,
anonKey: PUBLIC_SUPABASE_ANON_KEY
}
};
try {
supabase = createClient(finalConfig.supabaseConfig.url, finalConfig.supabaseConfig.anonKey, {
auth: { persistSession: false }
});
} catch (error) {
console.error('Error starting supabase', error);
throw error;
}
return supabase;
}
+24
View File
@@ -0,0 +1,24 @@
import { nanoid } from 'nanoid';
import path from 'path';
/**
* Safely extracts the file extension from a filename.
* Returns an empty string if no extension is found.
*/
export function getExtension(filename: string): string {
if (!filename || typeof filename !== 'string') return '';
const ext = path.extname(filename).split('.');
return ext.length > 1 ? ext[ext.length - 1] : '';
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
*/
export function generateFileKey(fileName: string): string {
const ext = getExtension(fileName);
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
return `${nanoid()}.${ext}`;
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/']
}
}
});
@@ -0,0 +1,54 @@
<script>
import YoutubeVideo from './YoutubeVideo.svelte';
import UploadVideo from './UploadVideo.svelte';
import EmbedLink from './EmbedLink.svelte';
import * as CONSTANTS from './constants';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
export let lessonId = '';
const tabs = CONSTANTS.videoTabs;
let currentTab = tabs[0].value;
const onChange = (tab) => () => (currentTab = tab);
</script>
<section class="flex h-full w-full flex-col items-start gap-3 md:flex-row">
<div class="flex flex-row items-center gap-2 md:flex-col">
<p class="mb-3 w-full text-start text-sm font-normal text-[#4F4B4B] dark:text-[#b0a9a9]">
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')}
</p>
{#each tabs as item (item.value)}
<button
on:click={onChange(item.value)}
class={`my-1 w-full border px-4 py-3 ${
currentTab === item.value
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
: 'border border-gray-200 dark:border-neutral-600'
} flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
>
{#if $isFreePlan && item.value === 3}
<FlashFilled size={20} class="text-blue-700" />
{:else}
<svelte:component
this={item.icon}
size={20}
color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`}
/>
{/if}
<p>{$t(item.title)}</p>
</button>
{/each}
</div>
<main class="h-full w-full">
{#if currentTab === 1}
<YoutubeVideo />
{:else if currentTab === 2}
<EmbedLink />
{:else}
<UploadVideo {lessonId} />
{/if}
</main>
</section>
@@ -0,0 +1,47 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations';
export let disableSignup = false;
export let redirect = '';
export let isMobile = false;
export let onMobileClick: (() => void) | undefined = undefined;
function handleLogin() {
if (onMobileClick) onMobileClick();
goto('/login' + redirect);
}
function handleSignup() {
if (onMobileClick) onMobileClick();
goto('/signup' + redirect);
}
</script>
<div class="flex {isMobile ? 'flex-col items-center justify-center space-y-3' : ''}">
<li class={isMobile ? '' : 'block'}>
<div class="flex">
<PrimaryButton
label={$t('navigation.login')}
variant={VARIANTS.TEXT}
onClick={handleLogin}
className={isMobile ? 'w-full justify-center' : ''}
/>
</div>
</li>
{#if !disableSignup}
<li class={isMobile ? '' : 'block'}>
<div class="flex">
<PrimaryButton
label={$t('navigation.signup')}
variant={VARIANTS.CONTAINED}
onClick={handleSignup}
className={isMobile ? 'w-full justify-center' : ''}
/>
</div>
</li>
{/if}
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { TCustomLinks } from './types';
export let customLinks: TCustomLinks | undefined = undefined;
export let isMobile = false;
export let onMobileClick: (() => void) | undefined = undefined;
function handleLinkClick() {
if (onMobileClick) onMobileClick();
}
</script>
{#if customLinks}
<div class={isMobile ? 'space-y-4' : 'mr-4 hidden items-center space-x-6 lg:flex'}>
{#each customLinks.links as link}
<li>
<a
href={link.url}
target={link.openInNewTab ? '_blank' : '_self'}
rel={link.openInNewTab ? 'noopener noreferrer' : ''}
class={isMobile
? 'hover:text-primary-600 block rounded-md px-3 py-2 font-medium text-gray-700 transition-colors duration-200 hover:bg-gray-50'
: 'hover:text-primary-600 rounded-md px-4 py-2 font-medium text-gray-700 no-underline transition-all duration-200 ease-in-out hover:bg-gray-100'}
on:click={handleLinkClick}
>
{link.label}
</a>
</li>
{/each}
</div>
{/if}
@@ -0,0 +1,25 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
export let logo: string | undefined;
export let orgName: string | undefined;
</script>
<div class="logo">
<a
href="/"
title={`${$t('navigation.goto')} ${orgName || 'ClassroomIO'} ${$t('navigation.home')}`}
id="logo"
data-hveid="8"
>
<img
src={logo || '/logo-192.png'}
alt={`${orgName || 'ClassroomIO'} logo`}
class="mx-auto inline-block w-9 rounded"
data-atf="1"
/>
{#if orgName}
<span class="line-clamp-1 hidden text-sm font-medium md:block">{orgName}</span>
{/if}
</a>
</div>
@@ -0,0 +1,100 @@
<script lang="ts">
import { page } from '$app/stores';
import { user } from '$lib/utils/store/user';
import { type TCustomLinks } from './types';
import CustomLinks from './CustomLinks.svelte';
import AuthButtons from './AuthButtons.svelte';
export let mobileMenuOpen = false;
export let customLinks: TCustomLinks | undefined = undefined;
export let disableSignup = false;
export let redirect = '';
let mobileMenuRef: HTMLElement;
function closeMobileMenu() {
mobileMenuOpen = false;
}
</script>
<!-- Mobile Menu Overlay -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if mobileMenuOpen}
<div
class="mobile-menu-overlay fixed inset-0 z-50 bg-black bg-opacity-50 lg:hidden"
on:click={closeMobileMenu}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={mobileMenuRef}
class="mobile-menu-sidebar absolute right-0 top-0 h-full w-64 transform bg-white shadow-lg transition-transform duration-300 ease-in-out"
on:click|stopPropagation
>
<div class="flex items-center justify-between border-b border-gray-200 p-4">
<h3 class="text-lg font-semibold text-gray-800">Menu</h3>
<button
on:click={closeMobileMenu}
class="rounded-md p-2 text-gray-500 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-700"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<nav>
<ul class="space-y-4 divide-y-[1px]">
<CustomLinks {customLinks} isMobile={true} onMobileClick={closeMobileMenu} />
<!-- Login/Signup buttons in mobile menu -->
{#if !$user.isLoggedIn && !$page.url.pathname?.includes('/404')}
<li class="pt-4">
<AuthButtons
{disableSignup}
{redirect}
isMobile={true}
onMobileClick={closeMobileMenu}
/>
</li>
{/if}
</ul>
</nav>
</div>
</div>
{/if}
<style>
/* Mobile menu styles */
.mobile-menu-overlay {
animation: fadeIn 0.2s ease-in-out;
}
.mobile-menu-sidebar {
animation: slideIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
</style>
@@ -1,119 +1,81 @@
<script>
import { goto } from '$app/navigation';
<script lang="ts">
import { page } from '$app/stores';
import { user } from '$lib/utils/store/user';
import { isCoursePage } from '$lib/utils/functions/app';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { t } from '$lib/utils/functions/translations';
import type { TCustomLinks } from './types';
import Logo from './Logo.svelte';
import CustomLinks from './CustomLinks.svelte';
import AuthButtons from './AuthButtons.svelte';
import MobileMenu from './MobileMenu.svelte';
export let disableSignup = false;
export let logo;
export let orgName;
export let logo: string | undefined = undefined;
export let orgName: string | undefined = undefined;
export let isOrgSite = false;
export let backgroundColor = 'bg-white dark:bg-black';
export let customLinks: TCustomLinks | undefined = undefined;
let navClass = '';
let mobileMenuOpen = false;
const redirect = isCoursePage($page.url.pathname) ? `?redirect=${$page.url.pathname}` : '';
$: redirect = isCoursePage($page.url.pathname) ? `?redirect=${$page.url.pathname}` : '';
$: showLinks =
customLinks && customLinks.show && customLinks.links && customLinks.links.length > 0;
$: navClass = '';
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
</script>
<nav
class="{navClass} {backgroundColor} sticky top-0 z-50 flex w-full border-b border-l-0 border-r-0 border-t-0 border-gray-300 px-2 py-1"
>
<ul class="flex w-full items-center">
<div class="logo">
<a
href="/"
title={`${$t('navigation.goto')} ${orgName || 'ClassroomIO'} ${$t('navigation.home')}`}
data-hveid="8"
>
<img
src={logo || '/logo-192.png'}
alt={`${orgName || 'ClassroomIO'} logo`}
class="mx-auto inline-block w-9 rounded"
data-atf="1"
/>
{#if orgName}
<span class="line-clamp-1 hidden text-sm font-medium md:block">{orgName}</span>
{/if}
</a>
</div>
{#if $user.isLoggedIn}
<li class="hidden">
<a class="block" href="dashboard"> {$t('navigation.dashboard')} </a>
</li>
<li class="hidden">
<a class="block" href="courses"> {$t('navigation.courses')} </a>
</li>
<li class="hidden">
<a class="block" href="discussions"> {$t('navigation.discussion')} </a>
</li>
<li class="hidden">
<a class="block" href="people">{$t('navigation.people')} </a>
</li>
{/if}
<Logo {logo} {orgName} />
<span class="flex-grow" />
<!-- Mobile Menu Button - Only show when custom links exist -->
{#if isOrgSite && showLinks}
<button
class="mobile-menu-btn hover:text-primary-600 rounded-md p-2 text-gray-700 transition-colors duration-200 hover:bg-gray-100 lg:hidden"
on:click={toggleMobileMenu}
aria-label="Toggle mobile menu"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
</button>
{/if}
{#if customLinks && showLinks}
<CustomLinks {customLinks} />
{/if}
{#if $user.isLoggedIn}
{#if isOrgSite}
<li><a class="block" href="/lms"> {$t('navigation.goto_lms')} </a></li>
{/if}
{:else if !$page.url.pathname?.includes('/404')}
<li>
<div class="flex">
<PrimaryButton
label={$t('navigation.login')}
variant={VARIANTS.TEXT}
onClick={() => goto('/login' + redirect)}
/>
</div>
</li>
<li>
{#if !disableSignup}
<div class="flex">
<PrimaryButton
label={$t('navigation.signup')}
variant={VARIANTS.CONTAINED}
onClick={() => goto('/signup' + redirect)}
/>
</div>
{/if}
</li>
{:else if isOrgSite && !$page.url.pathname?.includes('/404')}
<!-- Hide login/signup buttons on mobile when custom links exist -->
<div class="hidden lg:block">
<AuthButtons {disableSignup} {redirect} />
</div>
{:else if !isOrgSite && !$page.url.pathname?.includes('/404')}
<AuthButtons {disableSignup} {redirect} />
{/if}
<!-- <li class="hidden">
<a
class="flex items-center"
href="https://t.me/classroomio"
target="_blank"
>
<img
alt="File:Telegram logo.svg"
class="mr-1"
src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Telegram_alternative_logo.svg/50px-Telegram_alternative_logo.svg.png"
decoding="async"
width="25"
height="25"
srcset="https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Telegram_alternative_logo.svg/50px-Telegram_alternative_logo.svg.png 1.5x, https://upload.wikimedia.org/wikipedia/commons/thumb/8/82/Telegram_logo.svg/1024px-Telegram_logo.svg.png 2x"
data-file-width="25"
data-file-height="25"
/>
<span><SendAlt32 class="carbon-icon dark:text-white" /> Канал</span>
<SendAlt32 class="carbon-icon dark:text-white" />
<span>Канал</span>
</a>
</li> -->
<!-- <li class="new-question">
<NewQuestion />
</li> -->
</ul>
{#if showLinks}
<MobileMenu bind:mobileMenuOpen {customLinks} {disableSignup} {redirect} />
{/if}
</nav>
<style>
@@ -122,17 +84,12 @@
padding: 0;
}
/* clearfix */
ul::after {
content: '';
display: block;
clear: both;
}
/* li.new-question {
margin-top: 10px;
} */
a {
text-decoration: none;
color: var(--main-primary-color);
@@ -141,9 +98,15 @@
display: flex;
gap: 0.5rem;
align-items: center;
flex-direction: column;
}
@media only screen and (max-width: 1002px) {
/* Mobile menu button styles */
.mobile-menu-btn {
display: none;
}
@media only screen and (max-width: 1023px) {
nav.hide {
display: none;
}
@@ -154,5 +117,9 @@
a {
padding: 0 0.5em;
}
.mobile-menu-btn {
display: block;
}
}
</style>
@@ -0,0 +1,20 @@
export type CustomLink = {
id: number;
label: string;
url: string;
openInNewTab: boolean;
};
export type TCustomLinks = {
show: boolean;
links: CustomLink[];
};
export type TNavigationProps = {
disableSignup?: boolean;
logo?: string;
orgName?: string;
isOrgSite?: boolean;
backgroundColor?: string;
customLinks?: TCustomLinks | null;
};
@@ -177,6 +177,13 @@
<PageLoader />
{:else}
<main>
<Navigation
logo={org.avatar_url}
orgName={org.name}
disableSignup={true}
isOrgSite={true}
customLinks={$landingPageSettings.customLinks}
/>
<!-- Header Section -->
{#if $landingPageSettings.header.show}
<header
@@ -188,13 +195,6 @@
}`}
style="background-image: {getBgImage($landingPageSettings)}"
>
<Navigation
logo={org.avatar_url}
orgName={org.name}
disableSignup={true}
isOrgSite={true}
/>
<div class="absolute top-0 z-10 h-[100vh] w-full bg-white opacity-80 md:h-[90vh]" />
{#if $landingPageSettings.header.banner.show}
<div class="flex items-center justify-center py-2 md:h-full">
@@ -27,6 +27,7 @@
let isSaving = false;
let creatingNewQuestion = false;
let creatingNewCustomLink = false;
let hasUnsavedChanges = false;
let widgetKey = '';
const banner = [
@@ -40,6 +41,12 @@
content: ''
};
let newCustomLink = {
label: '',
url: '',
openInNewTab: false
};
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
@@ -77,6 +84,40 @@
$landingPageSettings.faq.questions = filteredFaq;
}
function createNewCustomLink() {
newCustomLink = {
label: '',
url: '',
openInNewTab: false
};
creatingNewCustomLink = true;
}
function saveNewCustomLink() {
if (newCustomLink.label !== '' && newCustomLink.url !== '') {
$landingPageSettings.customLinks.links = [
...$landingPageSettings.customLinks.links,
{
id: new Date().getTime(),
label: newCustomLink.label,
url: newCustomLink.url,
openInNewTab: newCustomLink.openInNewTab
}
];
creatingNewCustomLink = false;
}
}
function cancelNewCustomLink() {
creatingNewCustomLink = false;
}
function deleteCustomLink(id: number) {
let customLinks = $landingPageSettings.customLinks.links;
const filteredCustomLinks = customLinks.filter((link) => link.id !== id);
$landingPageSettings.customLinks.links = filteredCustomLinks;
}
const checkPrefix = (inputValue: string) => {
if (!inputValue) return;
@@ -103,7 +144,7 @@
const message = error?.message || 'snackbar.lms.error.try_again';
snackbar.error(`snackbar.lms.error.update ${message}`);
} else {
$currentOrg.landingpage = $landingPageSettings;
$currentOrg.landingpage = $landingPageSettings as any;
snackbar.success('snackbar.success_update');
hasUnsavedChanges = false;
}
@@ -124,7 +165,7 @@
$landingPageSettings = {
...landingpage
};
} as OrgLandingPageJson;
}
}
$: setDefault($currentOrg?.landingpage as unknown as OrgLandingPageJson);
@@ -578,6 +619,99 @@
</Column>
</Row>
<Row class="border-bottom-c flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}
><SectionTitle>{$t('settings.landing_page.custom_links.heading')}</SectionTitle>
<Toggle bind:toggled={$landingPageSettings.customLinks.show} size="sm">
<span slot="labelA" style="color: gray"
>{$t('settings.landing_page.custom_links.hide_links')}</span
>
<span slot="labelB" style="color: gray"
>{$t('settings.landing_page.custom_links.show_links')}</span
>
</Toggle>
</Column>
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
<p class="mb-4 text-sm text-gray-600">
{$t('settings.landing_page.custom_links.description')}
</p>
{#each $landingPageSettings.customLinks.links as link (link.id)}
<div class="mb-4 rounded-lg border border-gray-200 p-4">
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField
label={$t('settings.landing_page.custom_links.label')}
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
bind:value={link.label}
className="mb-3"
/>
<TextField
label={$t('settings.landing_page.custom_links.url')}
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
bind:value={link.url}
className="mb-3"
/>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input type="checkbox" bind:checked={link.openInNewTab} class="mr-2" />
<span class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</span>
</label>
<IconButton onClick={() => deleteCustomLink(link.id)}>
<TrashCan size={20} class="fill-red-700" />
</IconButton>
</div>
</div>
{/each}
{#if creatingNewCustomLink}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField
label={$t('settings.landing_page.custom_links.label')}
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
bind:value={newCustomLink.label}
className="mb-3"
/>
<TextField
label={$t('settings.landing_page.custom_links.url')}
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
bind:value={newCustomLink.url}
className="mb-3"
/>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input type="checkbox" bind:checked={newCustomLink.openInNewTab} class="mr-2" />
<span class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</span>
</label>
<div class="flex gap-2">
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.custom_links.save')}
onClick={saveNewCustomLink}
className="text-sm px-3 py-1"
/>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.custom_links.cancel')}
onClick={cancelNewCustomLink}
className="text-sm px-3 py-1"
/>
</div>
</div>
</div>
{:else}
<PrimaryButton
variant={VARIANTS.OUTLINED}
label={$t('settings.landing_page.custom_links.add')}
onClick={createNewCustomLink}
className="mt-2"
/>
{/if}
</Column>
</Row>
<Row class="flex flex-col py-7 lg:flex-row">
<Column sm={4} md={4} lg={4}
><SectionTitle>{$t('settings.landing_page.footer.heading')}</SectionTitle>
@@ -640,7 +774,7 @@
>
<span>
<IconButton onClick={handleSave} disabled={isSaving}>
<Save size={40} class=" rounded-full bg-blue-700 p-1" />
<Save size={32} class=" rounded-full bg-blue-700 p-1" />
</IconButton>
</span>
</div>
@@ -17,7 +17,7 @@ export interface OrgLandingPageJson {
type: string;
show: boolean;
};
background?: {
background: {
image: string;
show: boolean;
};
@@ -59,6 +59,15 @@ export interface OrgLandingPageJson {
buttonLabel: string;
show: boolean;
};
customLinks: {
show: boolean;
links: Array<{
id: number;
label: string;
url: string;
openInNewTab: boolean;
}>;
};
footer: {
facebook: string;
instagram: string;
@@ -145,6 +154,35 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
buttonLabel: 'Subscribe',
show: true
},
customLinks: {
show: true,
links: [
{
id: 1,
label: 'About Us',
url: '#aboutus',
openInNewTab: false
},
{
id: 2,
label: 'Courses',
url: '#courses',
openInNewTab: false
},
{
id: 3,
label: 'FAQ',
url: '#faq',
openInNewTab: false
},
{
id: 4,
label: 'Contact Us',
url: '#contact',
openInNewTab: false
}
]
},
footer: {
facebook: '#',
instagram: '#',
@@ -60,6 +60,35 @@
"buttonLabel": "Subscribe",
"show": true
},
"customLinks": {
"show": true,
"links": [
{
"id": 1,
"label": "About Us",
"url": "#about-us",
"openInNewTab": false
},
{
"id": 2,
"label": "Courses",
"url": "#courses",
"openInNewTab": false
},
{
"id": 3,
"label": "FAQ",
"url": "#faq",
"openInNewTab": false
},
{
"id": 4,
"label": "Contact Us",
"url": "#contact",
"openInNewTab": false
}
]
},
"footer": {
"facebook": "#",
"instagram": "#",
@@ -1286,6 +1286,20 @@
"title": "Hintergrundbild",
"show_background": "Hintergrund anzeigen",
"hide_background": "Hintergrund ausblenden"
},
"custom_links": {
"heading": "Benutzerdefinierte Links",
"hide_links": "Links verbergen",
"show_links": "Links anzeigen",
"description": "Fügen Sie benutzerdefinierte Navigationslinks hinzu, die in der Header -Navigationsleiste angezeigt werden.",
"label": "Etikett",
"url": "URL",
"label_placeholder": "z. B. über uns",
"url_placeholder": "z. B. #über- oder https://example.com",
"new_tab": "Offen in neuer Registerkarte",
"save": "Speichern",
"cancel": "Stornieren",
"add": "Neuen Link hinzufügen"
}
},
"organization": {
@@ -226,6 +226,21 @@
"twitter": "Twitter",
"linkedin": "LinkedIn"
},
"custom_links": {
"heading": "Custom Links",
"hide_links": "Hide Links",
"show_links": "Show Links",
"description": "Add custom navigation links that will appear in the header navigation bar.",
"label": "Label",
"url": "URL",
"label_placeholder": "e.g., About Us",
"url_placeholder": "e.g., #about-us or https://example.com",
"new_tab": "Open in new tab",
"save": "Save",
"cancel": "Cancel",
"add": "Add New Link"
},
"save_changes": "Save Changes"
},
"integrations": {
@@ -1286,6 +1286,20 @@
"title": "Imagen de fondo",
"show_background": "Mostrar fondo",
"hide_background": "Ocultar fondo"
},
"custom_links": {
"heading": "Enlaces personalizados",
"hide_links": "Ocultar enlaces",
"show_links": "Mostrar enlaces",
"description": "Agregue enlaces de navegación personalizados que aparecerán en la barra de navegación del encabezado.",
"label": "Etiqueta",
"url": "Url",
"label_placeholder": "por ejemplo, sobre nosotros",
"url_placeholder": "por ejemplo, #About-US o https://example.com",
"new_tab": "Abrir en una nueva pestaña",
"save": "Ahorrar",
"cancel": "Cancelar",
"add": "Agregar nuevo enlace"
}
},
"organization": {
@@ -1286,6 +1286,20 @@
"title": "Image d'arrière-plan",
"show_background": "Afficher l'arrière-plan",
"hide_background": "Masquer l'arrière-plan"
},
"custom_links": {
"heading": "Liens personnalisés",
"hide_links": "Masquer les liens",
"show_links": "Montrer des liens",
"description": "Ajoutez des liens de navigation personnalisés qui apparaîtront dans la barre de navigation d'en-tête.",
"label": "Étiquette",
"url": "URL",
"label_placeholder": "par exemple, à propos de nous",
"url_placeholder": "par exemple, # à peu près ou https://example.com",
"new_tab": "Ouvert dans un nouvel onglet",
"save": "Sauvegarder",
"cancel": "Annuler",
"add": "Ajouter un nouveau lien"
}
},
"organization": {
@@ -1286,6 +1286,20 @@
"title": "पृष्ठभूमि छवि",
"show_background": "पृष्ठभूमि दिखाएँ",
"hide_background": "पृष्ठभूमि छिपाएँ"
},
"custom_links": {
"heading": "कस्टम लिंक",
"hide_links": "लिंक छिपाएं",
"show_links": "लिंक दिखाएं",
"description": "हेडर नेविगेशन बार में दिखाई देने वाले कस्टम नेविगेशन लिंक जोड़ें।",
"label": "लेबल",
"url": "यूआरएल",
"label_placeholder": "जैसे, हमारे बारे में",
"url_placeholder": "उदा।, #के बारे में या https://example.com",
"new_tab": "वेब टेब में खोलें",
"save": "बचाना",
"cancel": "रद्द करना",
"add": "नया लिंक जोड़ें"
}
},
"organization": {
@@ -1402,6 +1402,20 @@
"title": "Obraz tła",
"show_background": "Pokaż tło",
"hide_background": "Ukryj tło"
},
"custom_links": {
"heading": "Niestandardowe linki",
"hide_links": "Ukryj linki",
"show_links": "Pokaż linki",
"description": "Dodaj niestandardowe linki nawigacyjne, które pojawią się na pasku nawigacji nagłówka.",
"label": "Etykieta",
"url": "URL",
"label_placeholder": "np. O nas",
"url_placeholder": "np. #About-US lub https://example.com",
"new_tab": "Otwórz w nowej zakładce",
"save": "Ratować",
"cancel": "Anulować",
"add": "Dodaj nowy link"
}
},
"organization": {
@@ -1286,6 +1286,20 @@
"title": "Imagem de fundo",
"show_background": "Mostrar plano de fundo",
"hide_background": "Ocultar plano de fundo"
},
"custom_links": {
"heading": "Links personalizados",
"hide_links": "Ocultar links",
"show_links": "Mostre links",
"description": "Adicione links de navegação personalizados que aparecerão na barra de navegação do cabeçalho.",
"label": "Rótulo",
"url": "Url",
"label_placeholder": "por exemplo, sobre nós",
"url_placeholder": "por exemplo, #sobre-us ou https://example.com",
"new_tab": "Abrir na nova guia",
"save": "Salvar",
"cancel": "Cancelar",
"add": "Adicione um novo link"
}
},
"organization": {
@@ -1286,6 +1286,20 @@
"title": "Фоновое изображение",
"show_background": "Показать фон",
"hide_background": "Скрыть фон"
},
"custom_links": {
"heading": "Пользовательские ссылки",
"hide_links": "Скрыть ссылки",
"show_links": "Показать ссылки",
"description": "Добавьте пользовательские навигационные ссылки, которые появятся в панели навигации заголовка.",
"label": "Этикетка",
"url": "URL",
"label_placeholder": "например, о нас",
"url_placeholder": "Например, #about-us или https://example.com",
"new_tab": "Открыто в новой вкладке",
"save": "Сохранять",
"cancel": "Отмена",
"add": "Добавить новую ссылку"
}
},
"organization": {
@@ -1286,6 +1286,20 @@
"title": "Hình nền",
"show_background": "Hiển thị nền",
"hide_background": "Ẩn nền"
},
"custom_links": {
"heading": "Liên kết tùy chỉnh",
"hide_links": "Ẩn liên kết",
"show_links": "Hiển thị liên kết",
"description": "Thêm các liên kết điều hướng tùy chỉnh sẽ xuất hiện trong thanh điều hướng tiêu đề.",
"label": "Nhãn",
"url": "URL",
"label_placeholder": "ví dụ: về chúng tôi",
"url_placeholder": "ví dụ: #abing-us hoặc https://example.com",
"new_tab": "Mở trong tab mới",
"save": "Cứu",
"cancel": "Hủy bỏ",
"add": "Thêm liên kết mới"
}
},
"organization": {