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:
co-authored by
Rotimi Best
parent
9a7018eb34
commit
27ee51155e
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
@@ -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=
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
@@ -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 "The Ultimate Guide to relocating to the UK via
|
||||||
|
Study Path." 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' 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&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 "The Ultimate Guide to relocating to the UK via Study
|
||||||
|
Path" 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>
|
||||||
@@ -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"]
|
||||||
@@ -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 "The Ultimate Guide to relocating to the UK via
|
||||||
|
Study Path." 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' 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&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 "The Ultimate Guide to relocating to the UK via Study
|
||||||
|
Path" 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>
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('+', '+').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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function genUniqueId(): string {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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/']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+54
@@ -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>
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { user } from '$lib/utils/store/user';
|
import { user } from '$lib/utils/store/user';
|
||||||
import { isCoursePage } from '$lib/utils/functions/app';
|
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 { 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 disableSignup = false;
|
||||||
export let logo;
|
export let logo: string | undefined = undefined;
|
||||||
export let orgName;
|
export let orgName: string | undefined = undefined;
|
||||||
export let isOrgSite = false;
|
export let isOrgSite = false;
|
||||||
export let backgroundColor = 'bg-white dark:bg-black';
|
export let backgroundColor = 'bg-white dark:bg-black';
|
||||||
|
export let customLinks: TCustomLinks | undefined = undefined;
|
||||||
|
|
||||||
let navClass = '';
|
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>
|
</script>
|
||||||
|
|
||||||
<nav
|
<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"
|
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">
|
<ul class="flex w-full items-center">
|
||||||
<div class="logo">
|
<Logo {logo} {orgName} />
|
||||||
<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}
|
|
||||||
|
|
||||||
<span class="flex-grow" />
|
<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 $user.isLoggedIn}
|
||||||
{#if isOrgSite}
|
{#if isOrgSite}
|
||||||
<li><a class="block" href="/lms"> {$t('navigation.goto_lms')} </a></li>
|
<li><a class="block" href="/lms"> {$t('navigation.goto_lms')} </a></li>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if !$page.url.pathname?.includes('/404')}
|
{:else if isOrgSite && !$page.url.pathname?.includes('/404')}
|
||||||
<li>
|
<!-- Hide login/signup buttons on mobile when custom links exist -->
|
||||||
<div class="flex">
|
<div class="hidden lg:block">
|
||||||
<PrimaryButton
|
<AuthButtons {disableSignup} {redirect} />
|
||||||
label={$t('navigation.login')}
|
</div>
|
||||||
variant={VARIANTS.TEXT}
|
{:else if !isOrgSite && !$page.url.pathname?.includes('/404')}
|
||||||
onClick={() => goto('/login' + redirect)}
|
<AuthButtons {disableSignup} {redirect} />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
{#if !disableSignup}
|
|
||||||
<div class="flex">
|
|
||||||
<PrimaryButton
|
|
||||||
label={$t('navigation.signup')}
|
|
||||||
variant={VARIANTS.CONTAINED}
|
|
||||||
onClick={() => goto('/signup' + redirect)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/if}
|
{/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>
|
</ul>
|
||||||
|
|
||||||
|
{#if showLinks}
|
||||||
|
<MobileMenu bind:mobileMenuOpen {customLinks} {disableSignup} {redirect} />
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -122,17 +84,12 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* clearfix */
|
|
||||||
ul::after {
|
ul::after {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* li.new-question {
|
|
||||||
margin-top: 10px;
|
|
||||||
} */
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--main-primary-color);
|
color: var(--main-primary-color);
|
||||||
@@ -141,9 +98,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
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 {
|
nav.hide {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -154,5 +117,9 @@
|
|||||||
a {
|
a {
|
||||||
padding: 0 0.5em;
|
padding: 0 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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 />
|
<PageLoader />
|
||||||
{:else}
|
{:else}
|
||||||
<main>
|
<main>
|
||||||
|
<Navigation
|
||||||
|
logo={org.avatar_url}
|
||||||
|
orgName={org.name}
|
||||||
|
disableSignup={true}
|
||||||
|
isOrgSite={true}
|
||||||
|
customLinks={$landingPageSettings.customLinks}
|
||||||
|
/>
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
{#if $landingPageSettings.header.show}
|
{#if $landingPageSettings.header.show}
|
||||||
<header
|
<header
|
||||||
@@ -188,13 +195,6 @@
|
|||||||
}`}
|
}`}
|
||||||
style="background-image: {getBgImage($landingPageSettings)}"
|
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]" />
|
<div class="absolute top-0 z-10 h-[100vh] w-full bg-white opacity-80 md:h-[90vh]" />
|
||||||
{#if $landingPageSettings.header.banner.show}
|
{#if $landingPageSettings.header.banner.show}
|
||||||
<div class="flex items-center justify-center py-2 md:h-full">
|
<div class="flex items-center justify-center py-2 md:h-full">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
let isSaving = false;
|
let isSaving = false;
|
||||||
let creatingNewQuestion = false;
|
let creatingNewQuestion = false;
|
||||||
|
let creatingNewCustomLink = false;
|
||||||
let hasUnsavedChanges = false;
|
let hasUnsavedChanges = false;
|
||||||
let widgetKey = '';
|
let widgetKey = '';
|
||||||
const banner = [
|
const banner = [
|
||||||
@@ -40,6 +41,12 @@
|
|||||||
content: ''
|
content: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let newCustomLink = {
|
||||||
|
label: '',
|
||||||
|
url: '',
|
||||||
|
openInNewTab: false
|
||||||
|
};
|
||||||
|
|
||||||
function widgetControl(key: string) {
|
function widgetControl(key: string) {
|
||||||
widgetKey = key;
|
widgetKey = key;
|
||||||
$handleOpenWidget.open = true;
|
$handleOpenWidget.open = true;
|
||||||
@@ -77,6 +84,40 @@
|
|||||||
$landingPageSettings.faq.questions = filteredFaq;
|
$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) => {
|
const checkPrefix = (inputValue: string) => {
|
||||||
if (!inputValue) return;
|
if (!inputValue) return;
|
||||||
|
|
||||||
@@ -103,7 +144,7 @@
|
|||||||
const message = error?.message || 'snackbar.lms.error.try_again';
|
const message = error?.message || 'snackbar.lms.error.try_again';
|
||||||
snackbar.error(`snackbar.lms.error.update ${message}`);
|
snackbar.error(`snackbar.lms.error.update ${message}`);
|
||||||
} else {
|
} else {
|
||||||
$currentOrg.landingpage = $landingPageSettings;
|
$currentOrg.landingpage = $landingPageSettings as any;
|
||||||
snackbar.success('snackbar.success_update');
|
snackbar.success('snackbar.success_update');
|
||||||
hasUnsavedChanges = false;
|
hasUnsavedChanges = false;
|
||||||
}
|
}
|
||||||
@@ -124,7 +165,7 @@
|
|||||||
|
|
||||||
$landingPageSettings = {
|
$landingPageSettings = {
|
||||||
...landingpage
|
...landingpage
|
||||||
};
|
} as OrgLandingPageJson;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$: setDefault($currentOrg?.landingpage as unknown as OrgLandingPageJson);
|
$: setDefault($currentOrg?.landingpage as unknown as OrgLandingPageJson);
|
||||||
@@ -578,6 +619,99 @@
|
|||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</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">
|
<Row class="flex flex-col py-7 lg:flex-row">
|
||||||
<Column sm={4} md={4} lg={4}
|
<Column sm={4} md={4} lg={4}
|
||||||
><SectionTitle>{$t('settings.landing_page.footer.heading')}</SectionTitle>
|
><SectionTitle>{$t('settings.landing_page.footer.heading')}</SectionTitle>
|
||||||
@@ -640,7 +774,7 @@
|
|||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<IconButton onClick={handleSave} disabled={isSaving}>
|
<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>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface OrgLandingPageJson {
|
|||||||
type: string;
|
type: string;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
};
|
};
|
||||||
background?: {
|
background: {
|
||||||
image: string;
|
image: string;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
};
|
};
|
||||||
@@ -59,6 +59,15 @@ export interface OrgLandingPageJson {
|
|||||||
buttonLabel: string;
|
buttonLabel: string;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
};
|
};
|
||||||
|
customLinks: {
|
||||||
|
show: boolean;
|
||||||
|
links: Array<{
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
openInNewTab: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
footer: {
|
footer: {
|
||||||
facebook: string;
|
facebook: string;
|
||||||
instagram: string;
|
instagram: string;
|
||||||
@@ -145,6 +154,35 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
|||||||
buttonLabel: 'Subscribe',
|
buttonLabel: 'Subscribe',
|
||||||
show: true
|
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: {
|
footer: {
|
||||||
facebook: '#',
|
facebook: '#',
|
||||||
instagram: '#',
|
instagram: '#',
|
||||||
|
|||||||
@@ -60,6 +60,35 @@
|
|||||||
"buttonLabel": "Subscribe",
|
"buttonLabel": "Subscribe",
|
||||||
"show": true
|
"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": {
|
"footer": {
|
||||||
"facebook": "#",
|
"facebook": "#",
|
||||||
"instagram": "#",
|
"instagram": "#",
|
||||||
|
|||||||
@@ -1286,6 +1286,20 @@
|
|||||||
"title": "Hintergrundbild",
|
"title": "Hintergrundbild",
|
||||||
"show_background": "Hintergrund anzeigen",
|
"show_background": "Hintergrund anzeigen",
|
||||||
"hide_background": "Hintergrund ausblenden"
|
"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": {
|
"organization": {
|
||||||
|
|||||||
@@ -226,6 +226,21 @@
|
|||||||
"twitter": "Twitter",
|
"twitter": "Twitter",
|
||||||
"linkedin": "LinkedIn"
|
"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"
|
"save_changes": "Save Changes"
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
|
|||||||
@@ -1286,6 +1286,20 @@
|
|||||||
"title": "Imagen de fondo",
|
"title": "Imagen de fondo",
|
||||||
"show_background": "Mostrar fondo",
|
"show_background": "Mostrar fondo",
|
||||||
"hide_background": "Ocultar 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": {
|
"organization": {
|
||||||
|
|||||||
@@ -1286,6 +1286,20 @@
|
|||||||
"title": "Image d'arrière-plan",
|
"title": "Image d'arrière-plan",
|
||||||
"show_background": "Afficher l'arrière-plan",
|
"show_background": "Afficher l'arrière-plan",
|
||||||
"hide_background": "Masquer 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": {
|
"organization": {
|
||||||
|
|||||||
@@ -1286,6 +1286,20 @@
|
|||||||
"title": "पृष्ठभूमि छवि",
|
"title": "पृष्ठभूमि छवि",
|
||||||
"show_background": "पृष्ठभूमि दिखाएँ",
|
"show_background": "पृष्ठभूमि दिखाएँ",
|
||||||
"hide_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": {
|
"organization": {
|
||||||
|
|||||||
@@ -1402,6 +1402,20 @@
|
|||||||
"title": "Obraz tła",
|
"title": "Obraz tła",
|
||||||
"show_background": "Pokaż tło",
|
"show_background": "Pokaż tło",
|
||||||
"hide_background": "Ukryj 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": {
|
"organization": {
|
||||||
|
|||||||
@@ -1286,6 +1286,20 @@
|
|||||||
"title": "Imagem de fundo",
|
"title": "Imagem de fundo",
|
||||||
"show_background": "Mostrar plano de fundo",
|
"show_background": "Mostrar plano de fundo",
|
||||||
"hide_background": "Ocultar 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": {
|
"organization": {
|
||||||
|
|||||||
@@ -1286,6 +1286,20 @@
|
|||||||
"title": "Фоновое изображение",
|
"title": "Фоновое изображение",
|
||||||
"show_background": "Показать фон",
|
"show_background": "Показать фон",
|
||||||
"hide_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": {
|
"organization": {
|
||||||
|
|||||||
@@ -1286,6 +1286,20 @@
|
|||||||
"title": "Hình nền",
|
"title": "Hình nền",
|
||||||
"show_background": "Hiển thị nền",
|
"show_background": "Hiển thị nền",
|
||||||
"hide_background": "Ẩn 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": {
|
"organization": {
|
||||||
|
|||||||
Reference in New Issue
Block a user